Deep Replicate
Laravel's Model::replicate() makes one unsaved copy of one model. Deep Replicate copies a relationship graph that you choose into new, persisted records, in a single transaction. For each relation path you decide whether its records are copied, reused, or skipped.
Explicit graphs
Nothing is traversed unless you select it. Every path is copied, reused or skipped on purpose.
Every direct relation
HasOne, HasMany, BelongsTo, BelongsToMany, MorphOne, MorphMany, MorphTo, MorphToMany and MorphedByMany.
Safe by design
Reused records are never re-parented, linking keys are protected, and a failure rolls everything back.
One copy per record
An identity map copies a record reached through several paths once, and resolves cycles back to the new root.
Pivots kept intact
Every pivot row is recreated with all its columns, custom pivot models and polymorphic types included.
Inspect first
A read-only dry run reports the graph, counts and warnings without writing or firing events.
Start with dryRun() on a real record. It shows exactly which paths, models and pivot rows a replication would create.
#Installation
Install the package with Composer. Laravel discovers the service provider and the DeepReplicate facade automatically.
#Requirements
- PHP 8.1, 8.2 or 8.3
- Laravel 10.0 or newer within 10.x (
illuminate/database,illuminate/support,illuminate/container,illuminate/contracts)
Each Laravel major has its own release line with the same API. Pick the line that matches your application (see Compatibility).
#Composer Installation
composer require pharaonic/laravel-deep-replicateThere is no configuration file to publish and no migration to run.
#Add the trait (optional)
The trait adds deepReplicate() to a model. The facade and the DeepReplicator service work on any model without it.
1use Illuminate\Database\Eloquent\Model;2use Pharaonic\Laravel\DeepReplicate\Concerns\DeepReplicates;3 4class Order extends Model5{6 use DeepReplicates;7}You're all set. Select the relations to copy and call replicate().
#Basic Usage
#Why native replicate() is not enough
$order->replicate() returns one unsaved Order with the same attributes. It copies no related records, and it may carry the source's loaded relations into the clone. Deep Replicate builds on that primitive. It replicates every selected model, links the copies to each other, and saves the whole graph in one transaction.
#Three equivalent entry points
The model trait, the facade, and the injected service all return the same ReplicationBuilder. Nothing is read or written until you call replicate() or dryRun().
1use Pharaonic\Laravel\DeepReplicate\DeepReplicator; 2use Pharaonic\Laravel\DeepReplicate\Facades\DeepReplicate; 3 4// Model trait: the first argument is required. 5$result = $order->deepReplicate('items')->replicate(); 6 7// Facade 8$result = DeepReplicate::for($order)->copy('items')->replicate(); 9 10// Dependency injection11public function duplicate(Order $order, DeepReplicator $replicator)12{13 return $replicator->for($order, ['items'])->replicate()->model();14}#A selection is always required
There is no root-only deep replication. Use Laravel's replicate() for that.
1$order->deepReplicate(); // ArgumentCountError2$order->deepReplicate([]); // EmptyRelationshipSelectionException3DeepReplicate::for($order)->replicate(); // EmptyRelationshipSelectionException4DeepReplicate::for($order)->skip('payments')->replicate(); // EmptyRelationshipSelectionExceptionAt least one path must be selected as copy or reuse. Preloading relations never selects them.
#Selecting paths
Positional paths are copied. An associative array assigns copy, reuse or skip; strategy names are case-insensitive. Nested paths are flat dot paths.
1$order->deepReplicate('items'); 2$order->deepReplicate('items', 'customer'); 3$order->deepReplicate(['items', 'customer']); 4$order->deepReplicate(['items' => 'copy', 'customer' => 'reuse', 'payments' => 'skip']); 5 6DeepReplicate::for($order, [ 7 'items' => 'copy', 8 'items.options' => 'copy', 9 'items.product' => 'reuse',10 'customer' => 'reuse',11 'payments' => 'skip',12])->replicate();The fluent methods do the same and can be mixed with the array forms:
1$result = $order->deepReplicate('items', 'items.options')2 ->reuse('items.product', 'customer')3 ->skip('payments')4 ->replicate();5 6$newOrder = $result->model(); // persisted root7$map = $result->map(); // original => copy8$stats = $result->stats(); // counts1$result = DeepReplicate::for($order)2 ->copy('items', 'items.options')3 ->reuse('items.product', 'customer')4 ->skip('payments')5 ->replicate();6 7$newOrder = $result->model(); // persisted root8$map = $result->map(); // original => copy9$stats = $result->stats(); // countsEvery example in these docs works with both APIs. The trait's arguments are the facade's first copy() (or relations() array) call:
| Trait | Facade |
|---|---|
$order->deepReplicate('items') |
DeepReplicate::for($order)->copy('items') |
$order->deepReplicate('items', 'customer') |
DeepReplicate::for($order)->copy('items', 'customer') |
$order->deepReplicate(['customer' => 'reuse']) |
DeepReplicate::for($order)->reuse('customer') |
$order->deepReplicate([...]) |
DeepReplicate::for($order, [...]) |
| Strategy | Effect |
|---|---|
copy |
Creates new related records and links them to the new parent. |
reuse |
Links the new parent to the existing records, without changing them. |
skip |
No query and no link. A foreign key already held by the copy is kept. |
#Relationships
#Support matrix
| Relation | copy |
reuse |
skip |
Notes |
|---|---|---|---|---|
HasOne |
Yes | Rejected | Yes | Reusing would move the child away from its original parent. |
HasMany |
Yes | Rejected | Yes | Children are never re-parented. |
BelongsTo |
Yes | Yes | Yes | A copied parent is inserted first and the copy's foreign key points to it. |
BelongsToMany |
Yes | Yes | Yes | Both strategies create new pivot rows for the new parent. |
MorphOne |
Yes | Rejected | Yes | The copy gets the new morph id and type. |
MorphMany |
Yes | Rejected | Yes | Same as MorphOne. |
MorphTo |
Yes | Yes | Yes | The concrete class comes from the stored type and the morph map. |
MorphToMany |
Yes | Yes | Yes | The pivot morph type and both keys are written. |
MorphedByMany |
Yes | Yes | Yes | The inverse side of MorphToMany. |
HasOneThrough, HasManyThrough |
Rejected | Rejected | Yes | Select the intermediate relations explicitly instead. |
Custom Relation classes |
Rejected | Rejected | Yes | Only the classes above are replicated, matched by exact class. |
Rejected combinations throw UnsupportedReuseException or UnsupportedRelationshipException before anything is written.
#Foreign keys you did not select
A copy keeps every raw attribute of its source except the primary key and the managed timestamps. Foreign keys of unselected or skipped relations keep their original values.
1$new = $post->deepReplicate('comments')->replicate()->model();2 3$new->author_id === $post->author_id; // true, although "author" was not selected1$new = DeepReplicate::for($post)->copy('comments')->replicate()->model();2 3$new->author_id === $post->author_id; // true, although "author" was not selectedA retained foreign key points to the original record. That is usually what you want (the same author, the same customer). It can also link the copy to data of another tenant or account. Select the relation as copy, or override the key with with(), when it must change.
#Copy and reuse of a parent
1$order->deepReplicate('customer')->replicate(); // new customer, new customer_id2$order->deepReplicate(['customer' => 'reuse'])->replicate(); // same customer_id1DeepReplicate::for($order)->copy('customer')->replicate(); // new customer, new customer_id2DeepReplicate::for($order)->reuse('customer')->replicate(); // same customer_idA selected BelongsTo or MorphTo with a null key stays null and adds no record. When the key is set but the query does not return the record (it is missing, filtered, or hidden by a scope), the key is kept and the dry run reports a missing_target warning.
#Polymorphic relations
MorphOne and MorphMany copies receive the new parent's id and its morph class, which is the morph map alias when one is registered. MorphTo resolves each record's class from its stored type through Relation::morphMap(), and keeps the stored type exactly as it is. A nested path through MorphTo must exist on every class it reaches:
1// Activities of posts and videos: Video has no "comments" relation.2$activity->deepReplicate('subject', 'subject.comments')->replicate();3// InvalidRelationshipPathException: ... [subject.comments] ... Video1// Activities of posts and videos: Video has no "comments" relation.2DeepReplicate::for($activity)->copy('subject', 'subject.comments')->replicate();3// InvalidRelationshipPathException: ... [subject.comments] ... Video#What counts as a relation
A path segment must be a public, non-static method without required parameters, declared by your model, that returns an Eloquent relation. Framework and package methods such as delete, save, replicate, restore or deepReplicate are never called, and a method whose declared return type cannot be a relation is rejected without being called.
#Nested Graphs
#Paths and precedence
A nested path implies copy of its unselected parents: copy('items.options') also copies items. A skipped nested path implies nothing.
| Selection | Result |
|---|---|
copy('items'), reuse('items.product') |
Items are copied; their products are reused. |
copy('items.options') |
Items and options are copied. |
skip('items'), copy('items.options') |
IncompatibleRelationOptionsException: a skipped relation is not traversed. |
reuse('items'), copy('items.options') |
IncompatibleRelationOptionsException: reuse is a terminal. |
copy('items'), copy('items') |
Same as one call. |
copy('items'), reuse('items') |
ConflictingRelationshipRuleException, whatever the order. |
The whole selection is normalized and validated before any query runs.
#One copy per original record
Every original record gets at most one copy per operation. A record reached through two copy paths is copied once and linked from both.
1// "items" and "activeItems" both reach the active item: it is copied once.2$order->deepReplicate('items', 'activeItems')->replicate();1// "items" and "activeItems" both reach the active item: it is copied once.2DeepReplicate::for($order)->copy('items', 'activeItems')->replicate();Identity is the resolved connection, the model class, and the primary key with its type. Integer 1 and string "1" are different records.
A record that one path copies and another reuses throws ConflictingIdentityStrategyException. A record copied through two paths whose rules configure it differently throws ConflictingNodeConfigurationException. A rule set on only one of the paths applies to the shared copy.
#Cycles
A selected path that leads back to the root resolves to the new root, so no second root is created:
1$result = $post->deepReplicate('author', 'author.posts')3 ->replicate();4 5// The new author owns the new post and copies of the author's other posts.1$result = DeepReplicate::for($post)2 ->copy('author', 'author.posts')4 ->replicate();5 6// The new author owns the new post and copies of the author's other posts.#Insert order and foreign key cycles
Copies are inserted after the copies their foreign keys point to: a copied BelongsTo parent goes first, and a copied child goes after its parent. When the selection forms a foreign key cycle (a user's current_team_id points to a team whose owner_id points back to the user), the cycle is broken at its earliest-reached model. That model is inserted with the cycle column set to NULL, and the column is updated once its target exists.
When the database rejects that NULL value, the replication fails with UnresolvableForeignKeyCycleException and nothing is kept. Foreign key checks are never disabled.
The cycle is always broken at the model reached first, which is the root when the cycle includes it. If that column is NOT NULL while another column of the cycle is nullable, the operation still fails. Make the root's column nullable, or start the replication from the other model.
#Pivots
BelongsToMany, MorphToMany and MorphedByMany paths recreate one new pivot row per source pivot row. A copy path points the row to the related record's copy, and a reuse path points it to the original record.
#What is copied
- Every column of the pivot table, including columns not declared with
withPivot(). Values are copied raw, so casts are not applied twice. - Each source row separately: two rows for the same related record become two new rows. Rows are never collapsed with
sync(). - Not the surrogate key (
id, or the custom pivot model's key), which the database or the pivot model generates again. - New timestamps when the relation uses
withTimestamps(). WithpreserveTimestamps(), the source pivot timestamps are kept.
Pivot constraints declared on the relation, such as wherePivot(), decide which rows are copied.
1// The new post is linked to the same tags; original tags and pivot rows are untouched.2$post->deepReplicate(['tags' => 'reuse'])->replicate();1// The new post is linked to the same tags; original tags and pivot rows are untouched.2DeepReplicate::for($post)->reuse('tags')->replicate();#Transforming pivot rows
transformPivot() receives the non-linking attributes, the source pivot model and the context, and returns the attributes to insert. copyPivot() only states the default, so you never need it.
1$item->deepReplicate(['tags' => 'reuse'])2 ->copyPivot('tags')3 ->transformPivot('tags', function (array $attributes, $sourcePivot, ReplicationContext $context): array {4 $attributes['primary'] = false;5 6 return $attributes;7 })8 ->replicate();1DeepReplicate::for($item)2 ->reuse('tags')3 ->copyPivot('tags')4 ->transformPivot('tags', function (array $attributes, $sourcePivot, ReplicationContext $context): array {5 $attributes['primary'] = false;6 7 return $attributes;8 })9 ->replicate();Setting a parent key, related key, morph type or surrogate key throws ProtectedRelationshipAttributeException.
#Custom pivot models
A relation that uses ->using(CustomPivot::class) saves each new row through that pivot model, the way attach() does. Its casts and its model events run. Plain pivot tables are written with batched multi-row inserts, which fire no model events, as in Laravel itself. RelationRule::withoutEvents() on the pivot path silences custom pivot events.
#Constraints and limits
A unique pivot column that would be duplicated fails the insert and rolls back the whole replication. Change it with transformPivot(). maxPivotRows() (default 50,000) is checked while planning, before the first write.
#Attributes
#Root and path configuration
The builder's exclude(), with(), generate() and transform() configure the root copy only. configure() gives a RelationRule for one copied path, with the same methods plus withoutEvents() and preserveTimestamps().
1use Pharaonic\Laravel\DeepReplicate\RelationRule; 2use Pharaonic\Laravel\DeepReplicate\ReplicationContext; 3 4$order->deepReplicate('items') 5 ->exclude(['external_ref']) 6 ->with(['status' => 'draft']) 7 ->generate('slug', fn (Order $source, ReplicationContext $context) => 'order-copy-'.bin2hex(random_bytes(6))) 8 ->transform(function (array $attributes, Order $source, ReplicationContext $context): array { 9 $attributes['number'] .= '-COPY';10 11 return $attributes;12 })13 ->configure('items', function (RelationRule $rule): void {14 $rule->exclude(['external_id'])->with(['state' => 'pending']);15 })16 ->replicate(); 1use Pharaonic\Laravel\DeepReplicate\Facades\DeepReplicate; 2use Pharaonic\Laravel\DeepReplicate\RelationRule; 3use Pharaonic\Laravel\DeepReplicate\ReplicationContext; 4 5DeepReplicate::for($order) 6 ->copy('items') 7 ->exclude(['external_ref']) 8 ->with(['status' => 'draft']) 9 ->generate('slug', fn (Order $source, ReplicationContext $context) => 'order-copy-'.bin2hex(random_bytes(6)))10 ->transform(function (array $attributes, Order $source, ReplicationContext $context): array {11 $attributes['number'] .= '-COPY';12 13 return $attributes;14 })15 ->configure('items', function (RelationRule $rule): void {16 $rule->exclude(['external_id'])->with(['state' => 'pending']);17 })18 ->replicate();#Order of operations
For every copy:
- Laravel's
replicate()runs on the source (itsreplicatingevent fires). The primary key, the timestamps and the unique id columns are dropped. The source's loaded relations are removed from the copy. excluderemoves attributes.withsets attributes throughsetAttribute(), so mutators and casts apply.generatecallbacks run once per copied record, in registration order.transformcallbacks receive and return the raw (stored) attributes, in registration order.- The engine sets the linking keys of the selected relations.
beforeModelcallbacks run, then the copy is saved.
Raw values are copied as stored. JSON columns are not re-encoded, and a value encrypted by a mutator keeps its ciphertext, so it still decrypts to the same plaintext. $fillable, $guarded and $hidden never drop an attribute, because nothing is mass assigned.
#Protected attributes
The primary key and every linking key managed by a selected relation are protected: a BelongsTo foreign key, a MorphTo id and type, and the foreign key of a copied child. Changing one through exclude, with, generate, transform or beforeModel throws ProtectedRelationshipAttributeException. Foreign keys of unselected relations are yours to change:
1$order->deepReplicate('items')->with(['assigned_customer_id' => $bob->id])->replicate();1DeepReplicate::for($order)->copy('items')->with(['assigned_customer_id' => $bob->id])->replicate();#Unique values
Nothing is suffixed automatically. A unique slug, SKU or email copied as is fails the insert and rolls back the replication. Give it a value explicitly:
1$order->deepReplicate('items')2 ->generate('slug', fn () => Str::slug($order->number.'-'.Str::random(6)))3 ->configure('items', fn (RelationRule $rule) => $rule->generate('sku', fn (OrderItem $item) => $item->sku.'-2'))4 ->replicate();1DeepReplicate::for($order)2 ->copy('items')3 ->generate('slug', fn () => Str::slug($order->number.'-'.Str::random(6)))4 ->configure('items', fn (RelationRule $rule) => $rule->generate('sku', fn (OrderItem $item) => $item->sku.'-2'))5 ->replicate();#Primary keys, UUIDs and ULIDs
Incrementing keys come from the database. A non-incrementing key is generated by the model's native unique id API: uniqueIds() and newUniqueId(), as Laravel's HasUuids and HasUlids define them from 9.30. Without it, register a generator for the key, or the copy throws MissingPrimaryKeyGeneratorException:
1$document->deepReplicate('pages')2 ->generate('uuid', fn () => (string) Str::uuid())3 ->configure('pages', fn (RelationRule $rule) => $rule->generate('uuid', fn () => (string) Str::uuid()))4 ->replicate();1DeepReplicate::for($document)2 ->copy('pages')3 ->generate('uuid', fn () => (string) Str::uuid())4 ->configure('pages', fn (RelationRule $rule) => $rule->generate('uuid', fn () => (string) Str::uuid()))5 ->replicate();Other columns listed in uniqueIds() get new values too.
#Timestamps and soft deletes
Copies get new created_at and updated_at values, custom column names included. preserveTimestamps() keeps the source values on every copy and pivot row, and RelationRule::preserveTimestamps(false) opts one path out. A soft-deleted source is restored on its copy; preserveSoftDelete() keeps its deleted_at.
#Model defaults
A model using the trait may return fallback settings from deepReplicationDefaults(). They apply only to paths the caller selects, and they can never select a path or change a strategy.
1protected function deepReplicationDefaults(): array2{3 return [4 'with' => ['status' => 'draft'],5 'paths' => [6 'items' => ['exclude' => ['external_id']],7 ],8 ];9}The keys are exclude, with, generate, transform, without_events and preserve_timestamps, at the root and under paths. Builder settings win: with and generate merge per attribute, and the other keys are replaced when the builder sets them.
#Loaded and Fresh Relations
#Fresh queries by default
Selected relations are queried again, following their definition: its constraints, the global scopes, and the foreign key and morph constraints. A relation already loaded on the source is ignored, so a constrained eager load never shrinks the copy by accident.
1$post->load(['comments' => fn ($q) => $q->where('approved', true)]);2 3$post->deepReplicate('comments')->replicate(); // every comment4$post->deepReplicate('comments')->usingLoaded('comments')->replicate(); // the approved ones1$post->load(['comments' => fn ($q) => $q->where('approved', true)]);2 3DeepReplicate::for($post)->copy('comments')->replicate(); // every comment4DeepReplicate::for($post)->copy('comments')->usingLoaded('comments')->replicate(); // the approved ones#usingLoaded()
usingLoaded($path) uses exactly the loaded collection or model on every source parent of that path, and throws MissingLoadedRelationException when one is missing. It never loads anything. It does not apply to descendant paths: list each one, and load it on every parent.
1$order->load('items.options');2 3$order->deepReplicate('items.options')->usingLoaded('items', 'items.options')->replicate();1$order->load('items.options');2 3DeepReplicate::for($order)->copy('items.options')->usingLoaded('items', 'items.options')->replicate();A loaded pivot carries only the columns that were loaded. The dry run warns (unloaded_pivot_columns) when the table has more; load them with withPivot().
#filter()
filter($path, $callback) constrains the query of one selected path. The callback receives the relation, as an eager-load constraint does. Several filters on a path all apply. The source model and its relations are never modified.
1$order->deepReplicate('items.options')2 ->filter('items', fn ($query) => $query->where('active', true))3 ->replicate();1DeepReplicate::for($order)2 ->copy('items.options')3 ->filter('items', fn ($query) => $query->where('active', true))4 ->replicate();filter() and usingLoaded() on the same path throw IncompatibleRelationOptionsException. A filter on a BelongsTo or MorphTo path that excludes the parent keeps the original foreign key.
#Global scopes and soft deletes
Global scopes apply to every relation query. Soft-deleted children are therefore not copied unless you call withoutGlobalScopes(), which removes every global scope from the relation queries:
1$order->deepReplicate('items')->withoutGlobalScopes()->preserveSoftDelete()->replicate();1DeepReplicate::for($order)->copy('items')->withoutGlobalScopes()->preserveSoftDelete()->replicate();It also removes tenant, team and authorization scopes, so the copy can include records the current user must not see. Use it only when crossing those scopes is intended. The dry run reports a global_scopes_removed warning.
#Lifecycle
#Eloquent events
Events stay on by default. Every copy fires replicating, saving, creating, created and saved, and records read for the plan fire retrieved.
1$order->deepReplicate('items')->withoutEvents()->replicate(); // no model events at all2 3$order->deepReplicate('items.options')4 ->configure('items', fn (RelationRule $rule) => $rule->withoutEvents()) // items only5 ->replicate();1DeepReplicate::for($order)->copy('items')->withoutEvents()->replicate(); // no model events at all2 3DeepReplicate::for($order)4 ->copy('items.options')5 ->configure('items', fn (RelationRule $rule) => $rule->withoutEvents()) // items only6 ->replicate();withoutEvents() uses Laravel's own Model::withoutEvents() for the duration of the operation. Listener registration keeps working, and the original dispatcher is always restored. A path's withoutEvents() wraps only the replication and the insert of that path's copies (and of its custom pivots), never the root or other paths.
#Callbacks
| Callback | Runs | Receives |
|---|---|---|
beforeReplicate |
Once, after planning, before the first write, inside the transaction. | ReplicationContext |
beforeModel |
Once per copy, fully prepared and unsaved, just before its insert. | original, copy, context |
afterModel |
Once per copy, in traversal order, after all inserts, links and pivots. | original, copy, context |
afterReplicate |
Once, after every afterModel, before the commit. |
ReplicationResult, context |
afterCommit |
Once, after the outermost transaction commits. | ReplicationResult |
A beforeModel callback may change the copy, but not its primary key, its linking keys or its connection.
1$order->deepReplicate('items')2 ->afterModel(function (Model $original, Model $copy, ReplicationContext $context): void {3 // $copy->exists === true, links resolved4 })5 ->afterCommit(function (ReplicationResult $result): void {6 dispatch(new NotifyOrderCopied($result->model()->getKey()));7 })8 ->replicate();1DeepReplicate::for($order)2 ->copy('items')3 ->afterModel(function (Model $original, Model $copy, ReplicationContext $context): void {4 // $copy->exists === true, links resolved5 })6 ->afterCommit(function (ReplicationResult $result): void {7 dispatch(new NotifyOrderCopied($result->model()->getKey()));8 })9 ->replicate();An exception in any callback before the commit rolls the whole replication back. An exception in afterCommit is thrown to you, but the data is already committed and stays.
#afterCommit and your own transactions
When the replication opens the outermost transaction, afterCommit runs right after it commits. Inside a transaction you opened, the callbacks wait for your outermost commit and are dropped if you roll back. That needs Connection::afterCommit(), which Laravel ships from 8.x. On Laravel 6 and 7, registering afterCommit inside your transaction throws AfterCommitUnavailableException before anything is written. The callback never runs early.
A rollback cannot undo mail, HTTP calls, queued jobs or files written by callbacks and observers. Do that work in afterCommit, or through an outbox table written inside the transaction.
#The context
ReplicationContext gives root() (the original root), replica() (the new root, null before it is inserted), path() (null for the root), depth() (0 for the root) and connectionName().
#Transactions and Safety
#One transaction
replicate() runs in one transaction on the source model's connection. It plans the graph again inside that transaction, so a dry run's snapshot is never reused. Then it inserts, links, attaches pivots, runs the callbacks and commits. Any exception rolls back every row the operation wrote and is rethrown, with database errors kept as the original QueryException. Inside your own transaction the operation uses a savepoint, and your rollback also discards the replica.
The transaction is attempted once. A deadlock is not retried, because callbacks are not idempotent. Retry the whole call yourself if your application needs it.
#One connection
Every model of the graph must use the source's connection. A related model on another connection throws CrossConnectionReplicationException while planning, before any write. Connections are compared as resolved connection objects, so a model that names the default connection explicitly is not "another connection".
#Concurrency
The plan is read inside the transaction, so it reflects the transaction's isolation level, not a global snapshot. Under the default READ COMMITTED of MySQL or PostgreSQL, rows committed by others between two planning queries may be seen. Lock the source records (for example with lockForUpdate() in your own transaction) when concurrent writes must not change the graph.
#Security notes
- Relation paths are resolved through Eloquent metadata only; no SQL is built from path strings.
- Exception messages name classes, paths and columns, never attribute values.
- Global scopes and foreign key checks are never disabled implicitly.
- Files, media, cloud objects and external resources are not copied, even when an attribute holds their path. Copy them yourself in
afterCommit. - Builders are single-use objects with no shared state, so they are safe to create per request.
#Dry Run and Results
#dryRun()
dryRun() returns a ReplicationPlan. It runs the planning queries only: no INSERT, UPDATE or DELETE, no model events (retrieved included), and no lifecycle callbacks, transforms or generators. Filters do run, because they decide the graph.
1$plan = $order->deepReplicate('items', 'items.options')2 ->reuse('items.product')3 ->dryRun();4 5$plan->relationships(); // ['items' => 'copy', 'items.options' => 'copy', 'items.product' => 'reuse']6$plan->stats(); // projected ReplicationStats7$plan->estimatedModels(); // copies + reused records8$plan->estimatedPivotRows();9$plan->warnings(); // list of PlanningWarning (code(), path(), message()) 1$plan = DeepReplicate::for($order) 2 ->copy('items', 'items.options') 3 ->reuse('items.product') 4 ->dryRun(); 5 6$plan->relationships(); // ['items' => 'copy', 'items.options' => 'copy', 'items.product' => 'reuse'] 7$plan->stats(); // projected ReplicationStats 8$plan->estimatedModels(); // copies + reused records 9$plan->estimatedPivotRows();10$plan->warnings(); // list of PlanningWarning (code(), path(), message())The counts are a snapshot. The data may change before replicate() runs. A builder can run dryRun() any number of times before replicate().
#ReplicationResult
1$result = $order->deepReplicate('items')->reuse('items.product')->replicate();2 3$result->model(); // the new, persisted root (relations not loaded)4$result->map()->get($originalItem); // its copy, or null5$result->map()->isReused($product); // true6$result->map()->all(); // identity key => copy7$result->map()->reused(); // identity key => reused original8$result->stats()->toArray();1$result = DeepReplicate::for($order)->copy('items')->reuse('items.product')->replicate();2 3$result->model(); // the new, persisted root (relations not loaded)4$result->map()->get($originalItem); // its copy, or null5$result->map()->isReused($product); // true6$result->map()->all(); // identity key => copy7$result->map()->reused(); // identity key => reused original8$result->stats()->toArray();Identity keys read {connection}|{class}|{key type}:{key}, for example mysql|App\Models\Item|integer:42. Reused originals are never listed as copies.
| Stat | Meaning |
|---|---|
copiedModels() |
New models, the root included. A shared record counts once. |
reusedModels() |
Distinct reused records. |
skippedRelationships() |
Paths selected as skip. |
pivotRowsCreated() |
Pivot rows inserted (projected for a plan). |
maxDepthReached() |
The deepest level where a record was reached. |
durationMs() |
Duration of the operation (of the planning, for a plan). |
A builder is single-use. A second replicate(), or a dryRun() after replicate(), throws BuilderAlreadyExecutedException, even when the first call failed.
#Limits
| Method | Default | Enforced |
|---|---|---|
maxDepth(int) |
10 | Before any query, from the selected paths (root depth 0). |
maxModels(int) |
10,000 | While planning: copies plus reused records, the root included. |
maxPivotRows(int) |
50,000 | While planning. |
chunk(int) |
500 | Parents per relation query and rows per pivot insert. |
A limit throws ReplicationLimitExceededException before the first write. Values below 1 throw InvalidArgumentException.
#Performance
Planning reads each selected relation with one query per chunk of parents (one per morph type for MorphTo), never one per parent. Each copy is one INSERT through Eloquent, so events and generated keys work. Plain pivot rows are inserted in batches. The identity map keeps every planned record in memory, so memory grows linearly with the graph (O(V + E)).
Measured on the package's test suite (an order with items, eight options per item and one reused tag per item; SQLite in memory, PHP 8.0, Laravel 6):
| Models | Selects | Inserts | Time | Peak memory |
|---|---|---|---|---|
| 10 | 3 | 11 | 8 ms | 0.9 MB |
| 100 | 3 | 101 | 14 ms | 0.5 MB |
| 1,000 | 3 | 1,001 | 116 ms | 4.4 MB |
| 10,000 | 7 | 10,003 | 1,296 ms | 43.1 MB |
A network database adds one round trip per insert. Graphs much larger than these are better split into several operations.
#API Reference
#Entry points
| Method | Returns |
|---|---|
$model->deepReplicate(string|array $relations, string ...$paths) |
ReplicationBuilder (trait Concerns\DeepReplicates) |
DeepReplicate::for(Model $source, array|string|null $relations = null) |
ReplicationBuilder (facade) |
DeepReplicator::for(Model $source, array|string|null $relations = null) |
ReplicationBuilder (container service) |
#ReplicationBuilder
| Method | Description |
|---|---|
relations(array $rules) |
Select paths: list values are copied, keys take copy, reuse or skip. |
copy(string $path, string ...$paths) |
Copy these paths. |
reuse(string $path, string ...$paths) |
Reuse these paths. |
skip(string $path, string ...$paths) |
Skip these paths. |
exclude(array $attributes) |
Leave attributes out of the root copy. |
with(array $attributes) |
Set attributes on the root copy through mutators and casts. |
transform(callable $callback) |
Transform the root copy's raw attributes. |
generate(string $attribute, callable $callback) |
Generate one root attribute. |
configure(string $path, callable $callback) |
Configure a copied path through a RelationRule. |
filter(string $path, callable $callback) |
Constrain the query of a selected path. |
usingLoaded(string $path, string ...$paths) |
Use the loaded relations of these paths. |
copyPivot(string $path, string ...$paths) |
State that pivot attributes are copied (the default). |
transformPivot(string $path, callable $callback) |
Transform the non-linking attributes of new pivot rows. |
preserveTimestamps() |
Keep source timestamps on copies and pivot rows. |
preserveSoftDelete() |
Keep source deleted_at values. |
withoutGlobalScopes() |
Remove every global scope from relation queries. |
withoutEvents() |
Suppress model events during the operation. |
maxDepth(int), maxModels(int), maxPivotRows(int), chunk(int) |
Limits. |
beforeReplicate, beforeModel, afterModel, afterReplicate, afterCommit |
Lifecycle callbacks. |
dryRun(): ReplicationPlan |
Read-only plan. |
replicate(): ReplicationResult |
Run the replication. |
#RelationRule
| Method | Description |
|---|---|
exclude(array $attributes) |
Leave attributes out of the path's copies. |
with(array $attributes) |
Set attributes through mutators and casts. |
transform(callable $callback) |
Transform raw attributes; several run in order. |
generate(string $attribute, callable $callback) |
Generate one attribute per copied record. |
withoutEvents() |
Suppress model events for this path's copies. |
preserveTimestamps(bool $preserve = true) |
Override the builder's setting for this path. |
#Exceptions
Every exception extends Exceptions\DeepReplicationException, which extends RuntimeException.
| Exception | Thrown when |
|---|---|
EmptyRelationshipSelectionException |
No path is selected as copy or reuse. |
InvalidRelationshipPathException |
A path is malformed, undefined, not a relation, not selected for an option, or reaches an unknown morph type. |
InvalidRelationshipStrategyException |
A strategy is not copy, reuse or skip. |
ConflictingRelationshipRuleException |
One path has two strategies. |
IncompatibleRelationOptionsException |
An active path is under a skipped or reused one, an option targets an incompatible strategy, filter() meets usingLoaded(), or a pivot option targets a non-pivot relation. |
UnsupportedRelationshipException |
A through or custom relation is copied or reused. |
UnsupportedReuseException |
A HasOne, HasMany, MorphOne or MorphMany path is reused. |
MissingLoadedRelationException |
A usingLoaded() relation is not loaded on a source parent. |
UnsavedSourceModelException |
A model of the graph is not persisted or has no key. |
CrossConnectionReplicationException |
A model uses another connection. |
ConflictingIdentityStrategyException |
One record is both copied and reused. |
ConflictingNodeConfigurationException |
One copied record has two configurations or two values for a linking key. |
UnresolvableForeignKeyCycleException |
The database rejects the NULL used to break a foreign key cycle. |
ProtectedRelationshipAttributeException |
A primary or linking key, or a pivot linking column, is changed. |
MissingPrimaryKeyGeneratorException |
A non-incrementing key has no generator. |
ReplicationLimitExceededException |
A depth, model or pivot limit is exceeded. |
AfterCommitUnavailableException |
afterCommit cannot wait for the caller's outermost commit on this Laravel version. |
BuilderAlreadyExecutedException |
A builder runs a second time. |
ReplicationCancelledException |
A model event listener returned false and cancelled an insert. |
#Compatibility
Every Laravel major has its own release line with the same package API. Versions are N.minor.patch, where N is the Laravel major.
| Line | PHP | Laravel | Status |
|---|---|---|---|
| 6.x | 8.0 | 6.20+ | Tested with lowest and highest dependencies on PHP 8.0. |
| 7.x | 8.0 | 7.30+ | Tested with lowest and highest dependencies on PHP 8.0. |
| 8.x | 8.0, 8.1 | 8.75+ | Tested with lowest and highest dependencies on PHP 8.0 and 8.1. |
| 9.x | 8.0 to 8.2 | 9.33+ | Tested with lowest and highest dependencies on PHP 8.0, 8.1 and 8.2. |
| 10.x | 8.1 to 8.3 | 10.0+ | Tested with lowest and highest dependencies on PHP 8.1, 8.2 and 8.3. |
Further lines (11.x to 13.x) are opened one at a time, in order, each tested on every PHP version its Laravel major supports, up to PHP 8.5.
Laravel 10 no longer receives security fixes. This line is maintained for applications that still run it, but the framework release, and its advisories, are your application's choice.
#Version-specific behavior
| Feature | Laravel 6 and 7 | Laravel 8 and later |
|---|---|---|
afterCommit inside your own transaction |
Throws AfterCommitUnavailableException before writing. |
Waits for your outermost commit. |
| Native UUID and ULID keys | Laravel 6 has no HasUuids; register a generator or define uniqueIds() and newUniqueId() yourself. |
HasUuids and HasUlids (9.30+) are used automatically. |
The databases tested are SQLite with foreign keys enforced and, in CI, MySQL and PostgreSQL.
#Examples
#1. Clone an order as a template
Copy the items and their options, keep the products and the customer, and start the copy as a draft.
1use Pharaonic\Laravel\DeepReplicate\RelationRule; 2 3public function store(Order $order) 4{ 5 $copy = $order->deepReplicate('items', 'items.options') 6 ->reuse('items.product', 'customer') 7 ->skip('payments') 8 ->with(['status' => 'draft']) 9 ->generate('slug', fn () => 'order-'.Str::lower(Str::random(10)))10 ->configure('items', fn (RelationRule $rule) => $rule->exclude(['external_id'])->with(['state' => 'pending']))11 ->replicate()12 ->model();13 14 return redirect()->route('orders.edit', $copy);15} 1use Pharaonic\Laravel\DeepReplicate\Facades\DeepReplicate; 2use Pharaonic\Laravel\DeepReplicate\RelationRule; 3 4public function store(Order $order) 5{ 6 $copy = DeepReplicate::for($order) 7 ->copy('items', 'items.options') 8 ->reuse('items.product', 'customer') 9 ->skip('payments')10 ->with(['status' => 'draft'])11 ->generate('slug', fn () => 'order-'.Str::lower(Str::random(10)))12 ->configure('items', fn (RelationRule $rule) => $rule->exclude(['external_id'])->with(['state' => 'pending']))13 ->replicate()14 ->model();15 16 return redirect()->route('orders.edit', $copy);17}#2. Copy a catalog section with shared taxonomy
Products are copied with their variants; their tags stay shared, so only new pivot rows are written.
1$result = $category->deepReplicate('products', 'products.variants')2 ->reuse('products.tags')3 ->configure('products', fn (RelationRule $rule) => $rule->generate('sku', fn (Product $p) => $p->sku.'-'.$category->season))4 ->replicate();5 6$result->stats()->pivotRowsCreated(); // one per original product-tag row1$result = DeepReplicate::for($category)2 ->copy('products', 'products.variants')3 ->reuse('products.tags')4 ->configure('products', fn (RelationRule $rule) => $rule->generate('sku', fn (Product $p) => $p->sku.'-'.$category->season))5 ->replicate();6 7$result->stats()->pivotRowsCreated(); // one per original product-tag row#3. Translate an article without duplicating its taxonomy
1public function handle(Article $article, string $locale): Article 2{ 3 return DeepReplicate::for($article) 4 ->copy('sections', 'sections.blocks') 5 ->reuse('categories', 'tags', 'author') 6 ->with(['locale' => $locale, 'published_at' => null]) 7 ->generate('slug', fn (Article $source) => $source->slug.'-'.$locale) 8 ->afterCommit(fn ($result) => TranslateArticle::dispatch($result->model()->getKey(), $locale)) 9 ->replicate()10 ->model();11}1class Article extends Model2{3 use DeepReplicates;4 5 public function sections() { return $this->hasMany(Section::class); }6 public function categories() { return $this->belongsToMany(Category::class); }7 public function tags() { return $this->morphToMany(Tag::class, 'taggable'); }8 public function author() { return $this->belongsTo(User::class); }9}#4. Check a large copy before running it
1$builder = fn () => DeepReplicate::for($project)->copy('tasks', 'tasks.checklist')->reuse('tasks.assignees');2 3$plan = $builder()->dryRun();4 5if ($plan->estimatedModels() > 5000) {6 return back()->withErrors('This project is too large to duplicate at once.');7}8 9$builder()->maxModels(5000)->replicate();#5. Stay inside the tenant
Keep global scopes on (the default), and copy the relations whose keys must change instead of retaining them:
1// The copy keeps team_id: duplicating within the same team.2$board->deepReplicate('columns', 'columns.cards')->replicate();3 4// Moving to another team: set the unmentioned key explicitly and check access first.5Gate::authorize('create', [Board::class, $targetTeam]);6$board->deepReplicate('columns', 'columns.cards')->with(['team_id' => $targetTeam->id])->replicate();1// The copy keeps team_id: duplicating within the same team.2DeepReplicate::for($board)->copy('columns', 'columns.cards')->replicate();3 4// Moving to another team: set the unmentioned key explicitly and check access first.5Gate::authorize('create', [Board::class, $targetTeam]);6DeepReplicate::for($board)->copy('columns', 'columns.cards')->with(['team_id' => $targetTeam->id])->replicate();#Troubleshooting
#UNIQUE constraint failed on a slug, SKU or email
The value was copied as is, because nothing is suffixed automatically. Give it a value with generate(), with() or transform() on the root, or with configure() on the path. The failed replication left no rows behind.
#MissingPrimaryKeyGeneratorException
The model has a non-incrementing key and no native unique id generator. Register one with generate('<key>', ...) on that path, or use HasUuids or HasUlids on Laravel 9.30 or later.
#ProtectedRelationshipAttributeException
A callback, with, exclude or generate changed a primary key or a key managed by a selected relation. Change the relation's strategy instead, or leave the key to the engine.
#UnsupportedReuseException on a HasMany
Reusing children would move them away from their parent. Copy them, or skip them.
#The copy has fewer children than expected
A global scope (soft deletes, tenant scopes) or a relation constraint hid them, or a usingLoaded() relation was loaded with a constraint. Check dryRun()->estimatedModels(), and use withoutGlobalScopes() only when crossing scopes is intended.
#The copy still points to the original parent
That relation was not selected, or it was skipped or filtered out, so its foreign key was kept. Select it as copy, or set the key with with().
#AfterCommitUnavailableException
You registered afterCommit inside your own transaction on Laravel 6 or 7. Replicate outside the transaction, or dispatch the work yourself after your commit.
#UnresolvableForeignKeyCycleException
The selection forms a foreign key cycle and the column used to break it is NOT NULL. Make that column nullable, start from the other model, or do not select both directions.
#afterCommit never runs in my tests
RefreshDatabase wraps each test in a transaction that never commits. Use DatabaseMigrations or DatabaseTruncation for tests that depend on afterCommit.
Contributors
Amazing people who made this package possible
This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Deep Replicate!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.