├── LICENSE ├── README.md ├── composer.json ├── package.json └── src ├── Adapters └── Upchuck.php ├── AttachmentAdapter.php ├── Cloneable.php ├── Cloner.php └── ServiceProvider.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 BKWLD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloner 2 | 3 | [![Packagist](https://img.shields.io/packagist/v/BKWLD/cloner.svg)](https://packagist.org/packages/bkwld/cloner) [![Build Status](https://travis-ci.org/BKWLD/cloner.svg?branch=master)](https://travis-ci.org/BKWLD/cloner) [![Coverage Status](https://coveralls.io/repos/github/BKWLD/cloner/badge.svg?branch=master)](https://coveralls.io/github/BKWLD/cloner?branch=master) 4 | 5 | A trait for Laravel Eloquent models that lets you clone a model and it's relationships, including files. Even to another database. 6 | 7 | 8 | ## Installation 9 | 10 | To get started with Cloner, use Composer to add the package to your project's dependencies: 11 | 12 | ``` 13 | composer require bkwld/cloner 14 | ``` 15 |
16 | 17 | > Note: The Below step is optional in Laravel 5.5 or above! 18 | 19 | After installing the cloner package, register the service provider. 20 | 21 | ```php 22 | Bkwld\Cloner\ServiceProvider::class, 23 | ``` 24 | in your `config/app.php` configuration file: 25 | 26 | ```php 27 | 'providers' => [ 28 | /* 29 | * Package Service Providers... 30 | */ 31 | Bkwld\Cloner\ServiceProvider::class, 32 | ], 33 | ``` 34 | 35 | ## Usage 36 | 37 | Your model should now look like this: 38 | 39 | ```php 40 | class Article extends Eloquent { 41 | 42 | use \Bkwld\Cloner\Cloneable; 43 | } 44 | ``` 45 | 46 | You can clone an Article model like so: 47 | 48 | ```php 49 | $clone = Article::first()->duplicate(); 50 | ``` 51 | 52 | In this example, `$clone` is a new `Article` that has been saved to the database. To clone to a different database: 53 | 54 | ```php 55 | $clone = Article::first()->duplicateTo('production'); 56 | ``` 57 | 58 | Where `production` is the [connection name](https://laravel.com/docs/6.x/database#using-multiple-database-connections) of a different Laravel database connection. 59 | 60 | 61 | #### Cloning Relationships 62 | 63 | Lets say your `Article` has many `Photos` (a one to many relationship) and can have more than one `Authors` (a many to many relationship). Now, your `Article` model should look like this: 64 | 65 | ```php 66 | class Article extends Eloquent { 67 | use \Bkwld\Cloner\Cloneable; 68 | 69 | protected $cloneable_relations = ['photos', 'authors']; 70 | 71 | public function photos() { 72 | return $this->hasMany('Photo'); 73 | } 74 | 75 | public function authors() { 76 | return $this->belongsToMany('Author'); 77 | } 78 | } 79 | ``` 80 | 81 | The `$cloneable_relations` informs the `Cloneable` as to which relations it should follow when cloning. 82 | Now when you call `Article::first()->duplicate()`, all of the `Photo` rows of the original will be copied and associated with the new `Article`. 83 | And new pivot rows will be created associating the new `Article` with the `Authors` of the original (because it is a many to many relationship, no new `Author` rows are created). 84 | Furthermore, if the `Photo` model has many of some other model, you can specify `$cloneable_relations` in its class and `Cloner` will continue replicating them as well. 85 | 86 | > **Note:** Many to many relationships will not be cloned to a _different_ database because the related instance may not exist in the other database or could have a different primary key. 87 | 88 | ### Customizing the cloned attributes 89 | 90 | By default, `Cloner` does not copy the `id` (or whatever you've defined as the `key` for the model) field; it assumes a new value will be auto-incremented. 91 | It also does not copy the `created_at` or `updated_at`. 92 | You can add additional attributes to ignore as follows: 93 | 94 | ```php 95 | class Photo extends Eloquent { 96 | use \Bkwld\Cloner\Cloneable; 97 | 98 | protected $clone_exempt_attributes = ['uid', 'source']; 99 | 100 | public function article() { 101 | return $this->belongsTo('Article'); 102 | } 103 | 104 | public function onCloning($src, $child = null) { 105 | $this->uid = str_random(); 106 | if($child) echo 'This was cloned as a relation!'; 107 | echo 'The original key is: '.$src->getKey(); 108 | } 109 | } 110 | ``` 111 | 112 | The `$clone_exempt_attributes` adds to the defaults. 113 | If you want to replace the defaults altogether, override the trait's `getCloneExemptAttributes()` method and return an array. 114 | 115 | Also, note the `onCloning()` method in the example. 116 | It is being used to make sure a unique column stays unique. 117 | The `Cloneable` trait adds to no-op callbacks that get called immediately before a model is saved during a duplication and immediately after: `onCloning()` and `onCloned()`. 118 | The `$child` parameter allows you to customize the behavior based on if it's being cloned as a relation or direct. 119 | 120 | In addition, Cloner fires the following Laravel events during cloning: 121 | 122 | - `cloner::cloning: ModelClass` 123 | - `cloner::cloned: ModelClass` 124 | 125 | `ModelClass` is the classpath of the model being cloned. 126 | The event payload contains the clone and the original model instances. 127 | 128 | 129 | ### Cloning files 130 | 131 | If your model references files saved disk, you'll probably want to duplicate those files and update the references. 132 | Otherwise, if the clone is deleted and it cascades delets, you will delete files referenced by your original model. `Cloner` allows you to specify a file attachment adapter and ships with support for [Bkwld\Upchuck](https://github.com/BKWLD/upchuck). 133 | Here's some example usage: 134 | 135 | ```php 136 | class Photo extends Eloquent { 137 | use \Bkwld\Cloner\Cloneable; 138 | 139 | protected $cloneable_file_attributes = ['image']; 140 | 141 | public function article() { 142 | return $this->belongsTo('Article'); 143 | } 144 | } 145 | ``` 146 | 147 | The `$cloneable_file_attributes` property is used by the `Cloneable` trait to identify which columns contain files. Their values are passed to the attachment adapter, which is responsible for duplicating the files and returning the path to the new file. 148 | 149 | If you don't use [Bkwld\Upchuck](https://github.com/BKWLD/upchuck) you can write your own implementation of the `Bkwld\Cloner\AttachmentAdapter` trait and wrap it in a Laravel IoC container named 'cloner.attachment-adapter'. 150 | For instance, put this in your `app/start/global.php`: 151 | 152 | ```php 153 | App::singleton('cloner.attachment-adapter', function($app) { 154 | return new CustomAttachmentAdapter; 155 | }); 156 | ``` 157 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bkwld/cloner", 3 | "description": "A trait for Laravel Eloquent models that lets you clone of a model and it's relationships, including files.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Robert Reinhard", 8 | "email": "info@bkwld.com" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=7.0", 13 | "illuminate/support": "^5.5|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "~6.0|~8.0|^9.5.10|^10.5", 17 | "bkwld/upchuck": "^2.6@dev", 18 | "illuminate/database": "^5.5|^6.0|^10.0|^11.0|^12.0", 19 | "league/flysystem-vfs": "^1.0", 20 | "mockery/mockery": "^1.2.3", 21 | "satooshi/php-coveralls": "^1.0" 22 | }, 23 | "suggest": { 24 | "bkwld/upchuck": "Required for replicating of files." 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Bkwld\\Cloner\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Bkwld\\Cloner\\Stubs\\": "stubs/" 34 | } 35 | }, 36 | "extra": { 37 | "laravel": { 38 | "providers": [ 39 | "Bkwld\\Cloner\\ServiceProvider" 40 | ] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cloner", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "devDependencies": { 6 | "gulp": "^3.9.0", 7 | "gulp-phpunit": "^0.8.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Adapters/Upchuck.php: -------------------------------------------------------------------------------- 1 | helpers = $helpers; 42 | $this->storage = $storage; 43 | $this->disk = $disk; 44 | } 45 | 46 | /** 47 | * Duplicate a file given it's URL 48 | * 49 | * @param string $url 50 | * @param \Illuminate\Database\Eloquent\Model $clone 51 | * @return string 52 | */ 53 | public function duplicate($url, $clone) { 54 | 55 | // Make the destination path 56 | $current_path = $this->helpers->path($url); 57 | $filename = basename($current_path); 58 | $dst_disk = $this->disks ? $this->disks->getFilesystem('dst') : $this->disk; 59 | $new_path = $this->storage->makeNestedAndUniquePath($filename, $dst_disk); 60 | 61 | // Copy, supporting alternative destination disks 62 | if ($this->disks) $this->disks->copy('src://'.$current_path, 'dst://'.$new_path); 63 | else $this->disk->copy($current_path, $new_path); 64 | 65 | // Return the Upchuck URL 66 | return $this->helpers->url($new_path); 67 | } 68 | 69 | /** 70 | * Set a different destination for cloned items. In doing so, create a 71 | * MountManager instance that will be used to do the copying 72 | * 73 | * @param \League\Flysystem\Filesystem $dst 74 | */ 75 | public function setDestination(Filesystem $dst) { 76 | $this->disks = new MountManager([ 77 | 'src' => $this->disk, 78 | 'dst' => $dst, 79 | ]); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/AttachmentAdapter.php: -------------------------------------------------------------------------------- 1 | getKeyName(), 21 | $this->getCreatedAtColumn(), 22 | $this->getUpdatedAtColumn(), 23 | ]; 24 | 25 | // Include the model count columns in the exempt columns 26 | $count_columns = array_map(function($count_column) { 27 | return $count_column . '_count'; 28 | }, $this->withCount); 29 | 30 | $defaults = array_merge($defaults, $count_columns); 31 | 32 | // It none specified, just return the defaults, else, merge them 33 | if (!isset($this->clone_exempt_attributes)) return $defaults; 34 | return array_merge($defaults, $this->clone_exempt_attributes); 35 | } 36 | 37 | /** 38 | * Return a list of attributes that reference files that should be duplicated 39 | * when the model is cloned 40 | * 41 | * @return array 42 | */ 43 | public function getCloneableFileAttributes() { 44 | if (!isset($this->cloneable_file_attributes)) return []; 45 | return $this->cloneable_file_attributes; 46 | } 47 | 48 | /** 49 | * Return the list of relations on this model that should be cloned 50 | * 51 | * @return array 52 | */ 53 | public function getCloneableRelations() { 54 | if (!isset($this->cloneable_relations)) return []; 55 | return $this->cloneable_relations; 56 | } 57 | 58 | /** 59 | * Add a relation to cloneable_relations uniquely 60 | * 61 | * @param string $relation 62 | * @return void 63 | */ 64 | public function addCloneableRelation($relation) { 65 | $relations = $this->getCloneableRelations(); 66 | if (in_array($relation, $relations)) return; 67 | $relations[] = $relation; 68 | $this->cloneable_relations = $relations; 69 | } 70 | 71 | /** 72 | * Clone the current model instance 73 | * @param array $attr Extra attributes for each clone 74 | * @return static The new, saved clone 75 | */ 76 | public function duplicate($attr = null) { 77 | return App::make('cloner')->duplicate($this, null, $attr); 78 | } 79 | 80 | /** 81 | * Clone the current model instance to a specific Laravel database connection 82 | * 83 | * @param string $connection A Laravel database connection 84 | * @param array $attr Extra attributes for each clone 85 | * @return static The new, saved clone 86 | */ 87 | public function duplicateTo($connection, $attr = null) { 88 | return App::make('cloner')->duplicateTo($this, $connection, $attr); 89 | } 90 | 91 | /** 92 | * A no-op callback that gets fired when a model is cloning but before it gets 93 | * committed to the database 94 | * 95 | * @param \Illuminate\Database\Eloquent\Model $src 96 | * @param boolean $child 97 | * @param array $attr Extra attributes for each clone 98 | * @return void 99 | */ 100 | public function onCloning($src, $child = null, $attr = null) {} 101 | 102 | /** 103 | * A no-op callback that gets fired when a model is cloned and saved to the 104 | * database 105 | * 106 | * @param \Illuminate\Database\Eloquent\Model $src 107 | * @return void 108 | */ 109 | public function onCloned($src) {} 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/Cloner.php: -------------------------------------------------------------------------------- 1 | attachment = $attachment; 36 | $this->events = $events; 37 | } 38 | 39 | /** 40 | * Clone a model instance and all of it's files and relations 41 | * 42 | * @param \Illuminate\Database\Eloquent\Model $model 43 | * @param \Illuminate\Database\Eloquent\Relations\Relation $relation 44 | * @param array $attr Extra attributes for each clone 45 | * @return \Illuminate\Database\Eloquent\Model The new model instance 46 | */ 47 | public function duplicate($model, $relation = null, $attr = null) { 48 | $clone = $this->cloneModel($model); 49 | 50 | $this->dispatchOnCloningEvent($clone, $relation, $model,null, $attr); 51 | 52 | if ($relation) { 53 | if (!is_a($relation, 'Illuminate\Database\Eloquent\Relations\BelongsTo')) { 54 | $relation->save($clone); 55 | } 56 | } else { 57 | $clone->save(); 58 | } 59 | 60 | $this->duplicateAttachments($model, $clone); 61 | $clone->save(); 62 | 63 | $this->cloneRelations($model, $clone); 64 | 65 | $this->dispatchOnClonedEvent($clone, $model); 66 | 67 | return $clone; 68 | } 69 | 70 | /** 71 | * Clone a model instance to a specific database connection 72 | * 73 | * @param \Illuminate\Database\Eloquent\Model $model 74 | * @param string $connection A Laravel database connection 75 | * @param array $attr Extra attributes for each clone 76 | * @return \Illuminate\Database\Eloquent\Model The new model instance 77 | */ 78 | public function duplicateTo($model, $connection, $attr = null) { 79 | $this->write_connection = $connection; // Store the write database connection 80 | $clone = $this->duplicate($model, null, $attr); // Do a normal duplicate 81 | $this->write_connection = null; // Null out the connection for next run 82 | return $clone; 83 | } 84 | 85 | /** 86 | * Create duplicate of the model 87 | * 88 | * @param \Illuminate\Database\Eloquent\Model $model 89 | * @return \Illuminate\Database\Eloquent\Model The new model instance 90 | */ 91 | protected function cloneModel($model) { 92 | $exempt = method_exists($model, 'getCloneExemptAttributes') ? 93 | $model->getCloneExemptAttributes() : null; 94 | $clone = $model->replicate($exempt); 95 | if ($this->write_connection) $clone->setConnection($this->write_connection); 96 | return $clone; 97 | } 98 | 99 | /** 100 | * Duplicate all attachments, given them a new name, and update the attribute 101 | * value 102 | * 103 | * @param \Illuminate\Database\Eloquent\Model $model 104 | * @param \Illuminate\Database\Eloquent\Model $clone 105 | * @return void 106 | */ 107 | protected function duplicateAttachments($model, $clone) { 108 | if (!$this->attachment || !method_exists($clone, 'getCloneableFileAttributes')) return; 109 | foreach($clone->getCloneableFileAttributes() as $attribute) { 110 | if (!$original = $model->getAttribute($attribute)) continue; 111 | $clone->setAttribute($attribute, $this->attachment->duplicate($original, $clone)); 112 | } 113 | } 114 | 115 | /** 116 | * @param \Illuminate\Database\Eloquent\Model $clone 117 | * @param \Illuminate\Database\Eloquent\Relations\Relation $relation 118 | * @param \Illuminate\Database\Eloquent\Model $src The orginal model 119 | * @param array $attr Extra attributes for each clone 120 | * @param boolean $child 121 | * @return void 122 | */ 123 | protected function dispatchOnCloningEvent($clone, $relation = null, $src = null, $child = null, $attr = null) 124 | { 125 | // Set the child flag 126 | if ($relation) $child = true; 127 | if($attr) $attr = json_decode(json_encode($attr), FALSE); 128 | // Notify listeners via callback or event 129 | if (method_exists($clone, 'onCloning')) $clone->onCloning($src, $child, $attr); 130 | $this->events->dispatch('cloner::cloning: '.get_class($src), [$clone, $src, $attr]); 131 | } 132 | 133 | /** 134 | * @param \Illuminate\Database\Eloquent\Model $clone 135 | * @param \Illuminate\Database\Eloquent\Model $src The orginal model 136 | * @return void 137 | */ 138 | protected function dispatchOnClonedEvent($clone, $src) 139 | { 140 | // Notify listeners via callback or event 141 | if (method_exists($clone, 'onCloned')) $clone->onCloned($src); 142 | $this->events->dispatch('cloner::cloned: '.get_class($src), [$clone, $src]); 143 | } 144 | 145 | /** 146 | * Loop through relations and clone or re-attach them 147 | * 148 | * @param \Illuminate\Database\Eloquent\Model $model 149 | * @param \Illuminate\Database\Eloquent\Model $clone 150 | * @return void 151 | */ 152 | protected function cloneRelations($model, $clone) { 153 | if (!method_exists($model, 'getCloneableRelations')) return; 154 | foreach($model->getCloneableRelations() as $relation_name) { 155 | $this->duplicateRelation($model, $relation_name, $clone); 156 | } 157 | } 158 | 159 | /** 160 | * Duplicate relationships to the clone 161 | * 162 | * @param \Illuminate\Database\Eloquent\Model $model 163 | * @param string $relation_name 164 | * @param \Illuminate\Database\Eloquent\Model $clone 165 | * @return void 166 | */ 167 | protected function duplicateRelation($model, $relation_name, $clone) { 168 | $relation = call_user_func([$model, $relation_name]); 169 | if (is_a($relation, 'Illuminate\Database\Eloquent\Relations\BelongsToMany')) { 170 | $this->duplicatePivotedRelation($relation, $relation_name, $clone); 171 | } else $this->duplicateDirectRelation($relation, $relation_name, $clone); 172 | } 173 | 174 | /** 175 | * Duplicate a many-to-many style relation where we are just attaching the 176 | * relation to the dupe 177 | * 178 | * @param \Illuminate\Database\Eloquent\Relations\Relation $relation 179 | * @param string $relation_name 180 | * @param \Illuminate\Database\Eloquent\Model $clone 181 | * @return void 182 | */ 183 | protected function duplicatePivotedRelation($relation, $relation_name, $clone) { 184 | 185 | // If duplicating between databases, do not duplicate relations. The related 186 | // instance may not exist in the other database or could have a different 187 | // primary key. 188 | if ($this->write_connection) return; 189 | 190 | // Loop trough current relations and attach to clone 191 | $relation->as('pivot')->get()->each(function ($foreign) use ($clone, $relation_name) { 192 | $pivot_attributes = Arr::except($foreign->pivot->getAttributes(), [ 193 | $foreign->pivot->getRelatedKey(), 194 | $foreign->pivot->getForeignKey(), 195 | $foreign->pivot->getCreatedAtColumn(), 196 | $foreign->pivot->getUpdatedAtColumn() 197 | ]); 198 | 199 | foreach (array_keys($pivot_attributes) as $attributeKey) { 200 | $pivot_attributes[$attributeKey] = $foreign->pivot->getAttribute($attributeKey); 201 | } 202 | 203 | if ($foreign->pivot->incrementing) { 204 | unset($pivot_attributes[$foreign->pivot->getKeyName()]); 205 | } 206 | 207 | $clone->$relation_name()->attach($foreign, $pivot_attributes); 208 | }); 209 | } 210 | 211 | /** 212 | * Duplicate a one-to-many style relation where the foreign model is ALSO 213 | * cloned and then associated 214 | * 215 | * @param \Illuminate\Database\Eloquent\Relations\Relation $relation 216 | * @param string $relation_name 217 | * @param \Illuminate\Database\Eloquent\Model $clone 218 | * @return void 219 | */ 220 | protected function duplicateDirectRelation($relation, $relation_name, $clone) { 221 | $relation->get()->each(function($foreign) use ($clone, $relation_name) { 222 | $cloned_relation = $this->duplicate($foreign, $clone->$relation_name()); 223 | if (is_a($clone->$relation_name(), 'Illuminate\Database\Eloquent\Relations\BelongsTo')) { 224 | $clone->$relation_name()->associate($cloned_relation); 225 | $clone->save(); 226 | } 227 | }); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton('cloner', function($app) { 21 | return new Cloner( 22 | $app['cloner.attachment-adapter'], 23 | $app['events'] 24 | ); 25 | }); 26 | 27 | // Instantiate default Upchuck attachment adapter if the app is using Upchuck. 28 | $this->app->singleton('cloner.attachment-adapter', function($app) { 29 | if (empty($app['upchuck'])) return; 30 | return new UpchuckAdapter( 31 | $app['upchuck'], 32 | $app['upchuck.storage'], 33 | $app['upchuck.disk'] 34 | ); 35 | }); 36 | 37 | } 38 | 39 | /** 40 | * Get the services provided by the provider. 41 | * 42 | * @return array 43 | */ 44 | public function provides() { 45 | return [ 46 | 'cloner', 47 | 'cloner.attachment-adapter', 48 | ]; 49 | } 50 | 51 | } 52 | --------------------------------------------------------------------------------