├── .styleci.yml ├── .gitignore ├── src ├── config │ └── revisionable.php ├── Venturecraft │ └── Revisionable │ │ ├── RevisionableServiceProvider.php │ │ ├── FieldFormatter.php │ │ ├── Revision.php │ │ ├── Revisionable.php │ │ └── RevisionableTrait.php └── migrations │ └── 2013_04_09_062329_create_revisions_table.php ├── CONTRIBUTING.md ├── tests ├── Models │ └── User.php ├── migrations │ ├── 2020_01_02_062330_add_additional_field_to_users.php │ └── 2020_01_02_062329_add_additional_field_to_revisions.php └── RevisionTest.php ├── phpunit.xml ├── LICENSE ├── composer.json └── readme.md /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr2 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | /.idea 4 | .phpunit.result.cache 5 | -------------------------------------------------------------------------------- /src/config/revisionable.php: -------------------------------------------------------------------------------- 1 | Venturecraft\Revisionable\Revision::class, 10 | 11 | 'additional_fields' => [], 12 | 13 | ]; 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Contributions are encouraged and welcome; to keep things organised, all bugs and requests should be 4 | opened in the GitHub "Issues" tab for the main project, at [venturecraft/revisionable/issues](https://github.com/venturecraft/revisionable/issues) 5 | 6 | Please submit all pull requests to the [revisionable/develop](https://github.com/VentureCraft/revisionable/tree/develop) branch, so they can be tested before being merged into the master branch. 7 | -------------------------------------------------------------------------------- /tests/Models/User.php: -------------------------------------------------------------------------------- 1 | string('additional_field')->nullable(); 17 | }); 18 | } 19 | 20 | /** 21 | * Reverse the migrations. 22 | * 23 | * @return void 24 | */ 25 | public function down() 26 | { 27 | // 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/migrations/2020_01_02_062329_add_additional_field_to_revisions.php: -------------------------------------------------------------------------------- 1 | string('additional_field')->nullable(); 17 | }); 18 | } 19 | 20 | /** 21 | * Reverse the migrations. 22 | * 23 | * @return void 24 | */ 25 | public function down() 26 | { 27 | // 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | ./tests/ 17 | 18 | 19 | 20 | 21 | 22 | src/ 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Venturecraft/Revisionable/RevisionableServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 17 | __DIR__ . '/../../config/revisionable.php' => config_path('revisionable.php'), 18 | ], 'config'); 19 | 20 | $this->publishes([ 21 | __DIR__ . '/../../migrations/' => database_path('migrations'), 22 | ], 'migrations'); 23 | } 24 | 25 | /** 26 | * Register the application services. 27 | * 28 | * @return void 29 | */ 30 | public function register() 31 | { 32 | } 33 | 34 | /** 35 | * Get the services provided by the provider. 36 | * 37 | * @return string[] 38 | */ 39 | public function provides() 40 | { 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/migrations/2013_04_09_062329_create_revisions_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 16 | $table->string('revisionable_type'); 17 | $table->unsignedBigInteger('revisionable_id'); 18 | $table->unsignedBigInteger('user_id')->nullable(); 19 | $table->string('key'); 20 | $table->text('old_value')->nullable(); 21 | $table->text('new_value')->nullable(); 22 | $table->timestamps(); 23 | 24 | $table->index(array('revisionable_id', 'revisionable_type')); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('revisions'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Davis Peixoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "venturecraft/revisionable", 3 | "license": "MIT", 4 | "description": "Keep a revision history for your models without thinking, created as a package for use with Laravel", 5 | "keywords": [ 6 | "model", 7 | "laravel", 8 | "ardent", 9 | "revision", 10 | "audit", 11 | "history" 12 | ], 13 | "homepage": "http://github.com/venturecraft/revisionable", 14 | "authors": [ 15 | { 16 | "name": "Chris Duell", 17 | "email": "me@chrisduell.com" 18 | } 19 | ], 20 | "support": { 21 | "issues": "https://github.com/VentureCraft/revisionable/issues", 22 | "source": "https://github.com/VentureCraft/revisionable" 23 | }, 24 | "require": { 25 | "php": ">=5.4.0", 26 | "illuminate/support": "~4.0|~5.0|~5.1|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", 27 | "laravel/framework": "~5.4|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0" 28 | }, 29 | "autoload": { 30 | "classmap": [ 31 | "src/migrations" 32 | ], 33 | "psr-0": { 34 | "Venturecraft\\Revisionable": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Venturecraft\\Revisionable\\Tests\\": "tests/" 40 | } 41 | }, 42 | "require-dev": { 43 | "orchestra/testbench": "~3.0|^8.0|^9.0|^10.0" 44 | }, 45 | "extra": { 46 | "laravel": { 47 | "providers": [ 48 | "Venturecraft\\Revisionable\\RevisionableServiceProvider" 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Venturecraft/Revisionable/FieldFormatter.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | 13 | /** 14 | * Class FieldFormatter 15 | * @package Venturecraft\Revisionable 16 | */ 17 | class FieldFormatter 18 | { 19 | /** 20 | * Format the value according to the provided formats. 21 | * 22 | * @param $key 23 | * @param $value 24 | * @param $formats 25 | * 26 | * @return string formatted value 27 | */ 28 | public static function format($key, $value, $formats) 29 | { 30 | foreach ($formats as $pkey => $format) { 31 | $parts = explode(':', $format); 32 | if (sizeof($parts) === 1) { 33 | continue; 34 | } 35 | 36 | if ($pkey == $key) { 37 | $method = array_shift($parts); 38 | 39 | if (method_exists(get_class(), $method)) { 40 | return self::$method($value, implode(':', $parts)); 41 | } 42 | break; 43 | } 44 | } 45 | 46 | return $value; 47 | } 48 | 49 | /** 50 | * Check if a field is empty. 51 | * 52 | * @param $value 53 | * @param array $options 54 | * 55 | * @return string 56 | */ 57 | public static function isEmpty($value, $options = array()) 58 | { 59 | $value_set = isset($value) && $value != ''; 60 | 61 | return sprintf(self::boolean($value_set, $options), $value); 62 | } 63 | 64 | /** 65 | * Boolean. 66 | * 67 | * @param $value 68 | * @param array $options The false / true values to return 69 | * 70 | * @return string Formatted version of the boolean field 71 | */ 72 | public static function boolean($value, $options = null) 73 | { 74 | if (!is_null($options)) { 75 | $options = explode('|', $options); 76 | } 77 | 78 | if (sizeof($options) != 2) { 79 | $options = array('No', 'Yes'); 80 | } 81 | 82 | return $options[!!$value]; 83 | } 84 | 85 | /** 86 | * Format the string response, default is to just return the string. 87 | * 88 | * @param $value 89 | * @param $format 90 | * 91 | * @return formatted string 92 | */ 93 | public static function string($value, $format = null) 94 | { 95 | if (is_null($format)) { 96 | $format = '%s'; 97 | } 98 | 99 | return sprintf($format, $value); 100 | } 101 | 102 | /** 103 | * Format the datetime 104 | * 105 | * @param string $value 106 | * @param string $format 107 | * 108 | * @return formatted datetime 109 | */ 110 | public static function datetime($value, $format = 'Y-m-d H:i:s') 111 | { 112 | if (empty($value)) { 113 | return null; 114 | } 115 | 116 | $datetime = new \DateTime($value); 117 | 118 | return $datetime->format($format); 119 | } 120 | 121 | /** 122 | * Format options 123 | * 124 | * @param string $value 125 | * @param string $format 126 | * @return string 127 | */ 128 | public static function options($value, $format) 129 | { 130 | $options = explode('|', $format); 131 | 132 | $result = []; 133 | 134 | foreach ($options as $option) { 135 | $transform = explode('.', $option); 136 | $result[$transform[0]] = $transform[1]; 137 | } 138 | 139 | if (isset($result[$value])) { 140 | return $result[$value]; 141 | } 142 | 143 | return 'undefined'; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /tests/RevisionTest.php: -------------------------------------------------------------------------------- 1 | loadLaravelMigrations(['--database' => 'testing']); 16 | 17 | // call migrations specific to our tests, e.g. to seed the db 18 | // the path option should be an absolute path. 19 | $this->loadMigrationsFrom([ 20 | '--database' => 'testing', 21 | '--path' => realpath(__DIR__.'/../src/migrations'), 22 | ]); 23 | } 24 | 25 | /** 26 | * Define environment setup. 27 | * 28 | * @param \Illuminate\Foundation\Application $app 29 | * @return void 30 | */ 31 | protected function getEnvironmentSetUp($app) 32 | { 33 | // Setup default database to use sqlite :memory: 34 | $app['config']->set('database.default', 'testbench'); 35 | $app['config']->set('database.connections.testbench', array( 36 | 'driver' => 'sqlite', 37 | 'database' => ':memory:', 38 | 'prefix' => '', 39 | )); 40 | } 41 | 42 | /** 43 | * Test we can interact with the database 44 | */ 45 | public function testUsersTable() 46 | { 47 | User::create([ 48 | 'name' => 'James Judd', 49 | 'email' => 'james.judd@revisionable.test', 50 | 'password' => \Hash::make('456'), 51 | ]); 52 | 53 | $users = User::findOrFail(1); 54 | $this->assertEquals('james.judd@revisionable.test', $users->email); 55 | $this->assertTrue(\Hash::check('456', $users->password)); 56 | } 57 | 58 | /** 59 | * Make sure revisions are created 60 | */ 61 | public function testRevisionsStored() 62 | { 63 | $user = User::create([ 64 | 'name' => 'James Judd', 65 | 'email' => 'james.judd@revisionable.test', 66 | 'password' => \Hash::make('456'), 67 | ]); 68 | 69 | // change to my nickname 70 | $user->update([ 71 | 'name' => 'Judd' 72 | ]); 73 | 74 | // change to my forename 75 | $user->update([ 76 | 'name' => 'James' 77 | ]); 78 | 79 | // we should have two revisions to my name 80 | $this->assertCount(2, $user->revisionHistory); 81 | } 82 | 83 | /** 84 | * Make sure additional fields are saved with revision 85 | */ 86 | public function testRevisionStoredAdditionalFields() 87 | { 88 | $this->loadMigrationsFrom([ 89 | '--database' => 'testing', 90 | '--path' => realpath(__DIR__.'/migrations'), 91 | ]); 92 | 93 | $this->app['config']->set('revisionable.additional_fields', ['additional_field']); 94 | 95 | $user = User::create([ 96 | 'name' => 'James Judd', 97 | 'email' => 'james.judd@revisionable.test', 98 | 'additional_field' => 678, 99 | 'password' => \Hash::make('456'), 100 | ]); 101 | 102 | 103 | // change to my nickname 104 | $user->update([ 105 | 'name' => 'Judd' 106 | ]); 107 | 108 | // we should have two revisions to my name 109 | $this->assertCount(1, $user->revisionHistory); 110 | 111 | $this->assertEquals(678, $user->revisionHistory->first()->additional_field); 112 | } 113 | 114 | /** 115 | * Make sure additional fields without values don't break 116 | */ 117 | public function testRevisionSkipsAdditionalFieldsWhenNotAvailable() 118 | { 119 | $this->loadMigrationsFrom([ 120 | '--database' => 'testing', 121 | '--path' => realpath(__DIR__.'/migrations'), 122 | ]); 123 | 124 | $this->app['config']->set('revisionable.additional_fields', ['additional_field']); 125 | 126 | $user = User::create([ 127 | 'name' => 'James Judd', 128 | 'email' => 'james.judd@revisionable.test', 129 | 'password' => \Hash::make('456'), 130 | ]); 131 | 132 | 133 | // change to my nickname 134 | $user->update([ 135 | 'name' => 'Judd' 136 | ]); 137 | 138 | // we should have two revisions to my name 139 | $this->assertCount(1, $user->revisionHistory); 140 | 141 | $this->assertNull($user->revisionHistory->first()->additional_field); 142 | } 143 | 144 | /** 145 | * Make sure additional fields which don't exist on the model still save revision 146 | */ 147 | public function testRevisionSkipsAdditionalFieldsWhenMisconfigured() 148 | { 149 | $this->loadMigrationsFrom([ 150 | '--database' => 'testing', 151 | '--path' => realpath(__DIR__.'/migrations'), 152 | ]); 153 | 154 | $this->app['config']->set('revisionable.additional_fields', ['unknown_field']); 155 | 156 | $user = User::create([ 157 | 'name' => 'James Judd', 158 | 'email' => 'james.judd@revisionable.test', 159 | 'password' => \Hash::make('456'), 160 | ]); 161 | 162 | 163 | // change to my nickname 164 | $user->update([ 165 | 'name' => 'Judd' 166 | ]); 167 | 168 | // we should have two revisions to my name 169 | $this->assertCount(1, $user->revisionHistory); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Venturecraft/Revisionable/Revision.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class Revision extends Eloquent 18 | { 19 | /** 20 | * @var string 21 | */ 22 | public $table = 'revisions'; 23 | 24 | /** 25 | * @var array 26 | */ 27 | protected $revisionFormattedFields = array(); 28 | 29 | /** 30 | * @param array $attributes 31 | */ 32 | public function __construct(array $attributes = array()) 33 | { 34 | parent::__construct($attributes); 35 | } 36 | 37 | /** 38 | * Revisionable. 39 | * 40 | * Grab the revision history for the model that is calling 41 | * 42 | * @return array revision history 43 | */ 44 | public function revisionable() 45 | { 46 | return $this->morphTo(); 47 | } 48 | 49 | /** 50 | * Field Name 51 | * 52 | * Returns the field that was updated, in the case that it's a foreign key 53 | * denoted by a suffix of "_id", then "_id" is simply stripped 54 | * 55 | * @return string field 56 | */ 57 | public function fieldName() 58 | { 59 | if ($formatted = $this->formatFieldName($this->key)) { 60 | return $formatted; 61 | } elseif (strpos($this->key, '_id')) { 62 | return str_replace('_id', '', $this->key); 63 | } else { 64 | return $this->key; 65 | } 66 | } 67 | 68 | /** 69 | * Format field name. 70 | * 71 | * Allow overrides for field names. 72 | * 73 | * @param $key 74 | * 75 | * @return bool 76 | */ 77 | private function formatFieldName($key) 78 | { 79 | $related_model = $this->getActualClassNameForMorph($this->revisionable_type); 80 | $related_model = new $related_model; 81 | $revisionFormattedFieldNames = $related_model->getRevisionFormattedFieldNames(); 82 | 83 | if (isset($revisionFormattedFieldNames[$key])) { 84 | return $revisionFormattedFieldNames[$key]; 85 | } 86 | 87 | return false; 88 | } 89 | 90 | /** 91 | * Old Value. 92 | * 93 | * Grab the old value of the field, if it was a foreign key 94 | * attempt to get an identifying name for the model. 95 | * 96 | * @return string old value 97 | */ 98 | public function oldValue() 99 | { 100 | return $this->getValue('old'); 101 | } 102 | 103 | 104 | /** 105 | * New Value. 106 | * 107 | * Grab the new value of the field, if it was a foreign key 108 | * attempt to get an identifying name for the model. 109 | * 110 | * @return string old value 111 | */ 112 | public function newValue() 113 | { 114 | return $this->getValue('new'); 115 | } 116 | 117 | 118 | /** 119 | * Responsible for actually doing the grunt work for getting the 120 | * old or new value for the revision. 121 | * 122 | * @param string $which old or new 123 | * 124 | * @return string value 125 | */ 126 | private function getValue($which = 'new') 127 | { 128 | $which_value = $which . '_value'; 129 | 130 | // First find the main model that was updated 131 | $main_model = $this->revisionable_type; 132 | // Load it, WITH the related model 133 | if (class_exists($main_model)) { 134 | $main_model = new $main_model; 135 | 136 | try { 137 | if ($this->isRelated()) { 138 | $related_model = $this->getRelatedModel(); 139 | 140 | // Now we can find out the namespace of of related model 141 | if (!method_exists($main_model, $related_model)) { 142 | $related_model = Str::camel($related_model); // for cases like published_status_id 143 | if (!method_exists($main_model, $related_model)) { 144 | throw new \Exception('Relation ' . $related_model . ' does not exist for ' . get_class($main_model)); 145 | } 146 | } 147 | $related_class = $main_model->$related_model()->getRelated(); 148 | 149 | // Finally, now that we know the namespace of the related model 150 | // we can load it, to find the information we so desire 151 | $item = $related_class::find($this->$which_value); 152 | 153 | if (is_null($this->$which_value) || $this->$which_value == '') { 154 | $item = new $related_class; 155 | 156 | return $item->getRevisionNullString(); 157 | } 158 | if (!$item) { 159 | $item = new $related_class; 160 | 161 | return $this->format($this->key, $item->getRevisionUnknownString()); 162 | } 163 | 164 | // Check if model use RevisionableTrait 165 | if(method_exists($item, 'identifiableName')) { 166 | // see if there's an available mutator 167 | $mutator = 'get' . Str::studly($this->key) . 'Attribute'; 168 | if (method_exists($item, $mutator)) { 169 | return $this->format($item->$mutator($this->key), $item->identifiableName()); 170 | } 171 | 172 | return $this->format($this->key, $item->identifiableName()); 173 | } 174 | } 175 | } catch (\Exception $e) { 176 | // Just a fail-safe, in the case the data setup isn't as expected 177 | // Nothing to do here. 178 | } 179 | 180 | // if there was an issue 181 | // or, if it's a normal value 182 | 183 | $mutator = 'get' . Str::studly($this->key) . 'Attribute'; 184 | if (method_exists($main_model, $mutator)) { 185 | return $this->format($this->key, $main_model->$mutator($this->$which_value)); 186 | } 187 | } 188 | 189 | return $this->format($this->key, $this->$which_value); 190 | } 191 | 192 | /** 193 | * Return true if the key is for a related model. 194 | * 195 | * @return bool 196 | */ 197 | private function isRelated() 198 | { 199 | $isRelated = false; 200 | $idSuffix = '_id'; 201 | $pos = strrpos($this->key, $idSuffix); 202 | 203 | if ($pos !== false 204 | && strlen($this->key) - strlen($idSuffix) === $pos 205 | ) { 206 | $isRelated = true; 207 | } 208 | 209 | return $isRelated; 210 | } 211 | 212 | /** 213 | * Return the name of the related model. 214 | * 215 | * @return string 216 | */ 217 | private function getRelatedModel() 218 | { 219 | $idSuffix = '_id'; 220 | 221 | return substr($this->key, 0, strlen($this->key) - strlen($idSuffix)); 222 | } 223 | 224 | /** 225 | * User Responsible. 226 | * 227 | * @return User user responsible for the change 228 | */ 229 | public function userResponsible() 230 | { 231 | if (empty($this->user_id)) { return false; } 232 | if (class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry') 233 | || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel') 234 | ) { 235 | return $class::findUserById($this->user_id); 236 | } else { 237 | $user_model = app('config')->get('auth.model'); 238 | 239 | if (empty($user_model)) { 240 | $user_model = app('config')->get('auth.providers.users.model'); 241 | if (empty($user_model)) { 242 | return false; 243 | } 244 | } 245 | if (!class_exists($user_model)) { 246 | return false; 247 | } 248 | return $user_model::find($this->user_id); 249 | } 250 | } 251 | 252 | /** 253 | * Returns the object we have the history of 254 | * 255 | * @return Object|false 256 | */ 257 | public function historyOf() 258 | { 259 | if (class_exists($class = $this->revisionable_type)) { 260 | return $class::find($this->revisionable_id); 261 | } 262 | 263 | return false; 264 | } 265 | 266 | /* 267 | * Examples: 268 | array( 269 | 'public' => 'boolean:Yes|No', 270 | 'minimum' => 'string:Min: %s' 271 | ) 272 | */ 273 | /** 274 | * Format the value according to the $revisionFormattedFields array. 275 | * 276 | * @param $key 277 | * @param $value 278 | * 279 | * @return string formatted value 280 | */ 281 | public function format($key, $value) 282 | { 283 | $related_model = $this->getActualClassNameForMorph($this->revisionable_type); 284 | $related_model = new $related_model; 285 | $revisionFormattedFields = $related_model->getRevisionFormattedFields(); 286 | 287 | if (isset($revisionFormattedFields[$key])) { 288 | return FieldFormatter::format($key, $value, $revisionFormattedFields); 289 | } else { 290 | return $value; 291 | } 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /src/Venturecraft/Revisionable/Revisionable.php: -------------------------------------------------------------------------------- 1 | 10 | * 11 | */ 12 | 13 | /** 14 | * Class Revisionable 15 | * @package Venturecraft\Revisionable 16 | */ 17 | class Revisionable extends Eloquent 18 | { 19 | /** 20 | * @var 21 | */ 22 | private $originalData; 23 | 24 | /** 25 | * @var 26 | */ 27 | private $updatedData; 28 | 29 | /** 30 | * @var 31 | */ 32 | private $updating; 33 | 34 | /** 35 | * @var array 36 | */ 37 | private $dontKeep = array(); 38 | 39 | /** 40 | * @var array 41 | */ 42 | private $doKeep = array(); 43 | 44 | /** 45 | * Keeps the list of values that have been updated 46 | * 47 | * @var array 48 | */ 49 | protected $dirtyData = array(); 50 | 51 | /** 52 | * Create the event listeners for the saving and saved events 53 | * This lets us save revisions whenever a save is made, no matter the 54 | * http method. 55 | */ 56 | public static function boot() 57 | { 58 | parent::boot(); 59 | 60 | static::saving(function ($model) { 61 | $model->preSave(); 62 | }); 63 | 64 | static::saved(function ($model) { 65 | $model->postSave(); 66 | }); 67 | 68 | static::created(function ($model) { 69 | $model->postCreate(); 70 | }); 71 | 72 | static::deleted(function ($model) { 73 | $model->preSave(); 74 | $model->postDelete(); 75 | $model->postForceDelete(); 76 | }); 77 | } 78 | /** 79 | * Instance the revision model 80 | * @return \Illuminate\Database\Eloquent\Model 81 | */ 82 | public static function newModel() 83 | { 84 | $model = app('config')->get('revisionable.model'); 85 | 86 | if (! $model) { 87 | $model = 'Venturecraft\Revisionable\Revision'; 88 | } 89 | 90 | return new $model; 91 | } 92 | 93 | /** 94 | * @return mixed 95 | */ 96 | public function revisionHistory() 97 | { 98 | return $this->morphMany(get_class(static::newModel()), 'revisionable'); 99 | } 100 | 101 | /** 102 | * Invoked before a model is saved. Return false to abort the operation. 103 | * 104 | * @return bool 105 | */ 106 | public function preSave() 107 | { 108 | if (!isset($this->revisionEnabled) || $this->revisionEnabled) { 109 | // if there's no revisionEnabled. Or if there is, if it's true 110 | 111 | $this->originalData = $this->original; 112 | $this->updatedData = $this->attributes; 113 | 114 | // we can only safely compare basic items, 115 | // so for now we drop any object based items, like DateTime 116 | foreach ($this->updatedData as $key => $val) { 117 | if (gettype($val) == 'object' && ! method_exists($val, '__toString')) { 118 | unset($this->originalData[$key]); 119 | unset($this->updatedData[$key]); 120 | } 121 | } 122 | 123 | // the below is ugly, for sure, but it's required so we can save the standard model 124 | // then use the keep / dontkeep values for later, in the isRevisionable method 125 | $this->dontKeep = isset($this->dontKeepRevisionOf) ? 126 | array_merge($this->dontKeepRevisionOf, $this->dontKeep) 127 | : $this->dontKeep; 128 | 129 | $this->doKeep = isset($this->keepRevisionOf) ? 130 | array_merge($this->keepRevisionOf, $this->doKeep) 131 | : $this->doKeep; 132 | 133 | unset($this->attributes['dontKeepRevisionOf']); 134 | unset($this->attributes['keepRevisionOf']); 135 | 136 | $this->dirtyData = $this->getDirty(); 137 | $this->updating = $this->exists; 138 | } 139 | } 140 | 141 | 142 | /** 143 | * Called after a model is successfully saved. 144 | * 145 | * @return void 146 | */ 147 | public function postSave() 148 | { 149 | 150 | // check if the model already exists 151 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled) && $this->updating) { 152 | // if it does, it means we're updating 153 | 154 | $changes_to_record = $this->changedRevisionableFields(); 155 | 156 | $revisions = array(); 157 | 158 | foreach ($changes_to_record as $key => $change) { 159 | $revisions[] = array( 160 | 'revisionable_type' => $this->getMorphClass(), 161 | 'revisionable_id' => $this->getKey(), 162 | 'key' => $key, 163 | 'old_value' => Arr::get($this->originalData, $key), 164 | 'new_value' => $this->updatedData[$key], 165 | 'user_id' => $this->getSystemUserId(), 166 | 'created_at' => new \DateTime(), 167 | 'updated_at' => new \DateTime(), 168 | ); 169 | } 170 | 171 | if (count($revisions) > 0) { 172 | $revision = static::newModel(); 173 | \DB::table($revision->getTable())->insert($revisions); 174 | } 175 | } 176 | } 177 | 178 | /** 179 | * Called after record successfully created 180 | */ 181 | public function postCreate() 182 | { 183 | 184 | // Check if we should store creations in our revision history 185 | // Set this value to true in your model if you want to 186 | if(empty($this->revisionCreationsEnabled)) 187 | { 188 | // We should not store creations. 189 | return false; 190 | } 191 | 192 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled)) 193 | { 194 | $revisions[] = array( 195 | 'revisionable_type' => $this->getMorphClass(), 196 | 'revisionable_id' => $this->getKey(), 197 | 'key' => self::CREATED_AT, 198 | 'old_value' => null, 199 | 'new_value' => $this->{self::CREATED_AT}, 200 | 'user_id' => $this->getSystemUserId(), 201 | 'created_at' => new \DateTime(), 202 | 'updated_at' => new \DateTime(), 203 | ); 204 | 205 | $revision = static::newModel(); 206 | \DB::table($revision->getTable())->insert($revisions); 207 | } 208 | } 209 | 210 | /** 211 | * If softdeletes are enabled, store the deleted time 212 | */ 213 | public function postDelete() 214 | { 215 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled) 216 | && $this->isSoftDelete() 217 | && $this->isRevisionable($this->getDeletedAtColumn())) { 218 | $revisions[] = array( 219 | 'revisionable_type' => $this->getMorphClass(), 220 | 'revisionable_id' => $this->getKey(), 221 | 'key' => $this->getDeletedAtColumn(), 222 | 'old_value' => null, 223 | 'new_value' => $this->{$this->getDeletedAtColumn()}, 224 | 'user_id' => $this->getSystemUserId(), 225 | 'created_at' => new \DateTime(), 226 | 'updated_at' => new \DateTime(), 227 | ); 228 | $revision = static::newModel(); 229 | \DB::table($revision->getTable())->insert($revisions); 230 | } 231 | } 232 | 233 | /** 234 | * If forcedeletes are enabled, set the value created_at of model to null 235 | * 236 | * @return void|bool 237 | */ 238 | public function postForceDelete() 239 | { 240 | if (empty($this->revisionForceDeleteEnabled)) { 241 | return false; 242 | } 243 | 244 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled) 245 | && (($this->isSoftDelete() && $this->isForceDeleting()) || !$this->isSoftDelete())) { 246 | 247 | $revisions[] = array( 248 | 'revisionable_type' => $this->getMorphClass(), 249 | 'revisionable_id' => $this->getKey(), 250 | 'key' => self::CREATED_AT, 251 | 'old_value' => $this->{self::CREATED_AT}, 252 | 'new_value' => null, 253 | 'user_id' => $this->getSystemUserId(), 254 | 'created_at' => new \DateTime(), 255 | 'updated_at' => new \DateTime(), 256 | ); 257 | 258 | $revision = Revisionable::newModel(); 259 | \DB::table($revision->getTable())->insert($revisions); 260 | \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions)); 261 | } 262 | } 263 | 264 | /** 265 | * Attempt to find the user id of the currently logged in user 266 | * Supports Cartalyst Sentry/Sentinel based authentication, as well as stock Auth 267 | **/ 268 | private function getSystemUserId() 269 | { 270 | try { 271 | if (class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry') 272 | || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel')) { 273 | return ($class::check()) ? $class::getUser()->id : null; 274 | } elseif (function_exists('backpack_auth') && backpack_auth()->check()) { 275 | return backpack_user()->id; 276 | } elseif (\Auth::check()) { 277 | return \Auth::user()->getAuthIdentifier(); 278 | } 279 | } catch (\Exception $e) { 280 | return null; 281 | } 282 | 283 | return null; 284 | } 285 | 286 | /** 287 | * Get all of the changes that have been made, that are also supposed 288 | * to have their changes recorded 289 | * 290 | * @return array fields with new data, that should be recorded 291 | */ 292 | private function changedRevisionableFields() 293 | { 294 | $changes_to_record = array(); 295 | foreach ($this->dirtyData as $key => $value) { 296 | // check that the field is revisionable, and double check 297 | // that it's actually new data in case dirty is, well, clean 298 | if ($this->isRevisionable($key) && !is_array($value)) { 299 | if (!isset($this->originalData[$key]) || $this->originalData[$key] != $this->updatedData[$key]) { 300 | $changes_to_record[$key] = $value; 301 | } 302 | } else { 303 | // we don't need these any more, and they could 304 | // contain a lot of data, so lets trash them. 305 | unset($this->updatedData[$key]); 306 | unset($this->originalData[$key]); 307 | } 308 | } 309 | 310 | return $changes_to_record; 311 | } 312 | 313 | /** 314 | * Check if this field should have a revision kept 315 | * 316 | * @param string $key 317 | * 318 | * @return bool 319 | */ 320 | private function isRevisionable($key) 321 | { 322 | 323 | // If the field is explicitly revisionable, then return true. 324 | // If it's explicitly not revisionable, return false. 325 | // Otherwise, if neither condition is met, only return true if 326 | // we aren't specifying revisionable fields. 327 | if (isset($this->doKeep) && in_array($key, $this->doKeep)) { 328 | return true; 329 | } 330 | if (isset($this->dontKeep) && in_array($key, $this->dontKeep)) { 331 | return false; 332 | } 333 | return empty($this->doKeep); 334 | } 335 | 336 | /** 337 | * Check if soft deletes are currently enabled on this model 338 | * 339 | * @return bool 340 | */ 341 | private function isSoftDelete() 342 | { 343 | // check flag variable used in laravel 4.2+ 344 | if (isset($this->forceDeleting)) { 345 | return !$this->forceDeleting; 346 | } 347 | 348 | // otherwise, look for flag used in older versions 349 | if (isset($this->softDelete)) { 350 | return $this->softDelete; 351 | } 352 | 353 | return false; 354 | } 355 | 356 | /** 357 | * @return mixed 358 | */ 359 | public function getRevisionFormattedFields() 360 | { 361 | return $this->revisionFormattedFields; 362 | } 363 | 364 | /** 365 | * @return mixed 366 | */ 367 | public function getRevisionFormattedFieldNames() 368 | { 369 | return $this->revisionFormattedFieldNames; 370 | } 371 | 372 | /** 373 | * Identifiable Name 374 | * When displaying revision history, when a foreign key is updated 375 | * instead of displaying the ID, you can choose to display a string 376 | * of your choice, just override this method in your model 377 | * By default, it will fall back to the models ID. 378 | * 379 | * @return string an identifying name for the model 380 | */ 381 | public function identifiableName() 382 | { 383 | return $this->getKey(); 384 | } 385 | 386 | /** 387 | * Revision Unknown String 388 | * When displaying revision history, when a foreign key is updated 389 | * instead of displaying the ID, you can choose to display a string 390 | * of your choice, just override this method in your model 391 | * By default, it will fall back to the models ID. 392 | * 393 | * @return string an identifying name for the model 394 | */ 395 | public function getRevisionNullString() 396 | { 397 | return isset($this->revisionNullString)?$this->revisionNullString:'nothing'; 398 | } 399 | 400 | /** 401 | * No revision string 402 | * When displaying revision history, if the revisions value 403 | * cant be figured out, this is used instead. 404 | * It can be overridden. 405 | * 406 | * @return string an identifying name for the model 407 | */ 408 | public function getRevisionUnknownString() 409 | { 410 | return isset($this->revisionUnknownString)?$this->revisionUnknownString:'unknown'; 411 | } 412 | 413 | /** 414 | * Disable a revisionable field temporarily 415 | * Need to do the adding to array longhanded, as there's a 416 | * PHP bug https://bugs.php.net/bug.php?id=42030 417 | * 418 | * @param mixed $field 419 | * 420 | * @return void 421 | */ 422 | public function disableRevisionField($field) 423 | { 424 | if (!isset($this->dontKeepRevisionOf)) { 425 | $this->dontKeepRevisionOf = array(); 426 | } 427 | if (is_array($field)) { 428 | foreach ($field as $one_field) { 429 | $this->disableRevisionField($one_field); 430 | } 431 | } else { 432 | $donts = $this->dontKeepRevisionOf; 433 | $donts[] = $field; 434 | $this->dontKeepRevisionOf = $donts; 435 | unset($donts); 436 | } 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Revisionable for Laravel 2 | 3 | [![Latest Version](https://img.shields.io/github/release/venturecraft/revisionable.svg?style=flat-square)](https://packagist.org/packages/venturecraft/revisionable) 4 | [![Downloads](https://img.shields.io/packagist/dt/venturecraft/revisionable.svg?style=flat-square)](https://packagist.org/packages/venturecraft/revisionable) 5 | [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://tldrlegal.com/license/mit-license) 6 | 7 | Wouldn't it be nice to have a revision history for any model in your project, without having to do any work for it. By simply adding the `RevisionableTrait` Trait to your model, you can instantly have just that, and be able to display a history similar to this: 8 | 9 | * Chris changed title from 'Something' to 'Something else' 10 | * Chris changed category from 'News' to 'Breaking news' 11 | * Matt changed category from 'Breaking news' to 'News' 12 | 13 | So not only can you see a history of what happened, but who did what, so there's accountability. 14 | 15 | Revisionable is a laravel package that allows you to keep a revision history for your models without thinking. For some background and info, [see this article](http://www.chrisduell.com/blog/development/keeping-revisions-of-your-laravel-model-data/) 16 | 17 | ## Working with 3rd party Auth / Eloquent extensions 18 | 19 | Revisionable has support for Auth powered by 20 | * [**Sentry by Cartalyst**](https://cartalyst.com/manual/sentry). 21 | * [**Sentinel by Cartalyst**](https://cartalyst.com/manual/sentinel). 22 | 23 | *(Recommended)* Revisionable can also now be used [as a Trait](#the-new-trait-based-implementation), so your models can continue to extend Eloquent, or any other class that extends Eloquent (like [Ardent](https://github.com/laravelbook/ardent)). 24 | 25 | ## Installation 26 | 27 | Revisionable is installable via [composer](https://getcomposer.org/doc/00-intro.md), the details are on [packagist, here.](https://packagist.org/packages/venturecraft/revisionable) 28 | 29 | Add the following to the `require` section of your projects composer.json file: 30 | 31 | ```php 32 | "venturecraft/revisionable": "1.*", 33 | ``` 34 | 35 | Run composer update to download the package 36 | 37 | ``` 38 | php composer.phar update 39 | ``` 40 | 41 | Open config/app.php and register the required service provider (Laravel 5.x) 42 | 43 | ``` 44 | 'providers' => [ 45 | Venturecraft\Revisionable\RevisionableServiceProvider::class, 46 | ] 47 | ``` 48 | 49 | Publish the configuration and migrations (Laravel 5.x) 50 | 51 | ``` 52 | php artisan vendor:publish --provider="Venturecraft\Revisionable\RevisionableServiceProvider" 53 | ``` 54 | 55 | Finally, you'll also need to run migration on the package (Laravel 5.x) 56 | 57 | ``` 58 | php artisan migrate 59 | ``` 60 | 61 | For Laravel 4.x users: 62 | ``` 63 | php artisan migrate --package=venturecraft/revisionable 64 | ``` 65 | 66 | > If you're going to be migrating up and down completely a lot (using `migrate:refresh`), one thing you can do instead is to copy the migration file from the package to your `app/database` folder, and change the classname from `CreateRevisionsTable` to something like `CreateRevisionTable` (without the 's', otherwise you'll get an error saying there's a duplicate class) 67 | 68 | > `cp vendor/venturecraft/revisionable/src/migrations/2013_04_09_062329_create_revisions_table.php database/migrations/` 69 | 70 | ## Docs 71 | 72 | * [Implementation](#intro) 73 | * [More control](#control) 74 | * [Format output](#formatoutput) 75 | * [Load revision history](#loadhistory) 76 | * [Display history](#display) 77 | * [Contributing](#contributing) 78 | * [Having troubles?](#faq) 79 | 80 | 81 | ## Implementation 82 | 83 | ### The new, Trait based implementation (recommended) 84 | > Traits require PHP >= 5.4 85 | 86 | For any model that you want to keep a revision history for, include the `VentureCraft\Revisionable` namespace and use the `RevisionableTrait` in your model, e.g., 87 | 88 | ```php 89 | namespace App; 90 | 91 | use \Venturecraft\Revisionable\RevisionableTrait; 92 | 93 | class Article extends \Illuminate\Database\Eloquent\Model { 94 | use RevisionableTrait; 95 | } 96 | ``` 97 | 98 | > Being a trait, Revisionable can now be used with the standard Eloquent model, or any class that extends Eloquent, such as [Ardent](https://github.com/laravelbook/ardent). 99 | 100 | ### Legacy class based implementation 101 | 102 | > The new trait based approach is backwards compatible with existing installations of Revisionable. You can still use the below installation instructions, which essentially is extending a wrapper for the trait. 103 | 104 | For any model that you want to keep a revision history for, include the `VentureCraft\Revisionable` namespace and use the `RevisionableTrait` in your model, e.g., 105 | 106 | ```php 107 | use Venturecraft\Revisionable\Revisionable; 108 | 109 | namespace App; 110 | 111 | class Article extends Revisionable { } 112 | ``` 113 | 114 | > Note: This also works with namespaced models. 115 | 116 | ### Implementation notes 117 | 118 | If needed, you can disable the revisioning by setting `$revisionEnabled` to false in your Model. This can be handy if you want to temporarily disable revisioning, or if you want to create your own base Model that extends Revisionable, which all of your models extend, but you want to turn Revisionable off for certain models. 119 | 120 | ```php 121 | namespace App; 122 | 123 | use \Venturecraft\Revisionable\RevisionableTrait; 124 | 125 | class Article extends \Illuminate\Database\Eloquent\Model { 126 | protected $revisionEnabled = false; 127 | } 128 | ``` 129 | 130 | You can also disable revisioning after X many revisions have been made by setting `$historyLimit` to the number of revisions you want to keep before stopping revisions. 131 | 132 | ```php 133 | namespace App; 134 | 135 | use \Venturecraft\Revisionable\RevisionableTrait; 136 | 137 | class Article extends \Illuminate\Database\Eloquent\Model { 138 | protected $revisionEnabled = true; 139 | protected $historyLimit = 500; //Stop tracking revisions after 500 changes have been made. 140 | } 141 | ``` 142 | In order to maintain a limit on history, but instead of stopping tracking revisions if you want to remove old revisions, you can accommodate that feature by setting `$revisionCleanup`. 143 | 144 | ```php 145 | namespace App; 146 | 147 | use \Venturecraft\Revisionable\RevisionableTrait; 148 | 149 | class Article extends \Illuminate\Database\Eloquent\Model { 150 | protected $revisionEnabled = true; 151 | protected $revisionCleanup = true; //Remove old revisions (works only when used with $historyLimit) 152 | protected $historyLimit = 500; //Maintain a maximum of 500 changes at any point of time, while cleaning up old revisions. 153 | } 154 | ``` 155 | 156 | ### Storing Soft Deletes 157 | By default, if your model supports soft deletes, Revisionable will store this and any restores as updates on the model. 158 | 159 | You can choose to ignore deletes and restores by adding `deleted_at` to your `$dontKeepRevisionOf` array. 160 | 161 | To better format the output for `deleted_at` entries, you can use the `isEmpty` formatter (see Format output for an example of this.) 162 | 163 | 164 | 165 | ### Storing Force Delete 166 | By default the Force Delete of a model is not stored as a revision. 167 | 168 | If you want to store the Force Delete as a revision you can override this behavior by setting `revisionForceDeleteEnabled ` to `true` by adding the following to your model: 169 | ```php 170 | protected $revisionForceDeleteEnabled = true; 171 | ``` 172 | 173 | In which case, the `created_at` field will be stored as a key with the `oldValue()` value equal to the model creation date and the `newValue()` value equal to `null`. 174 | 175 | **Attention!** Turn on this setting carefully! Since the model saved in the revision, now does not exist, so you will not be able to get its object or its relations. 176 | 177 | ### Storing Creations 178 | By default the creation of a new model is not stored as a revision. 179 | Only subsequent changes to a model is stored. 180 | 181 | If you want to store the creation as a revision you can override this behavior by setting `revisionCreationsEnabled` to `true` by adding the following to your model: 182 | ```php 183 | protected $revisionCreationsEnabled = true; 184 | ``` 185 | 186 | ## More Control 187 | 188 | No doubt, there'll be cases where you don't want to store a revision history only for certain fields of the model, this is supported in two different ways. In your model you can either specifiy which fields you explicitly want to track and all other fields are ignored: 189 | 190 | ```php 191 | protected $keepRevisionOf = ['title']; 192 | ``` 193 | 194 | Or, you can specify which fields you explicitly don't want to track. All other fields will be tracked. 195 | 196 | ```php 197 | protected $dontKeepRevisionOf = ['category_id']; 198 | ``` 199 | 200 | > The `$keepRevisionOf` setting takes precedence over `$dontKeepRevisionOf` 201 | 202 | ### Storing additional fields in revisions 203 | 204 | In some cases, you'll want additional metadata from the models in each revision. An example of this might be if you 205 | have to keep track of accounts as well as users. Simply create your own new migration to add the fields you'd like to your revision model, 206 | add them to your config/revisionable.php in an array like so: 207 | 208 | ```php 209 | 'additional_fields' => ['account_id', 'permissions_id', 'other_id'], 210 | ``` 211 | 212 | If the column exists in the model, it will be included in the revision. 213 | 214 | Make sure that if you can't guarantee the column in every model, you make that column ```nullable()``` in your migrations. 215 | 216 | 217 | ### Events 218 | 219 | Every time a model revision is created an event is fired. You can listen for `revisionable.created`, 220 | `revisionable.saved` or `revisionable.deleted`. 221 | 222 | ```php 223 | // app/Providers/EventServiceProvider.php 224 | 225 | public function boot() 226 | { 227 | parent::boot(); 228 | 229 | $events->listen('revisionable.*', function($model, $revisions) { 230 | // Do something with the revisions or the changed model. 231 | dd($model, $revisions); 232 | }); 233 | } 234 | 235 | ``` 236 | 237 | 238 | ## Format output 239 | 240 | > You can continue (and are encouraged to) use `Eloquent accessors` in your model to set the 241 | output of your values, see the [Laravel Documentation for more information on accessors](https://laravel.com/docs/eloquent-mutators#accessors-and-mutators) 242 | > The below documentation is therefor deprecated 243 | 244 | In cases where you want to have control over the format of the output of the values, for example a boolean field, you can set them in the `$revisionFormattedFields` array in your model. e.g., 245 | 246 | ```php 247 | protected $revisionFormattedFields = [ 248 | 'title' => 'string:%s', 249 | 'public' => 'boolean:No|Yes', 250 | 'modified' => 'datetime:m/d/Y g:i A', 251 | 'deleted_at' => 'isEmpty:Active|Deleted' 252 | ]; 253 | ``` 254 | 255 | You can also override the field name output using the `$revisionFormattedFieldNames` array in your model, e.g., 256 | 257 | ```php 258 | protected $revisionFormattedFieldNames = [ 259 | 'title' => 'Title', 260 | 'small_name' => 'Nickname', 261 | 'deleted_at' => 'Deleted At' 262 | ]; 263 | ``` 264 | 265 | This comes into play when you output the revision field name using `$revision->fieldName()` 266 | 267 | ### String 268 | To format a string, simply prefix the value with `string:` and be sure to include `%s` (this is where the actual value will appear in the formatted response), e.g., 269 | 270 | ``` 271 | string:%s 272 | ``` 273 | 274 | ### Boolean 275 | Booleans by default will display as a 0 or a 1, which is pretty bland and won't mean much to the end user, so this formatter can be used to output something a bit nicer. Prefix the value with `boolean:` and then add your false and true options separated by a pipe, e.g., 276 | 277 | ``` 278 | boolean:No|Yes 279 | ``` 280 | 281 | ### Options 282 | Analogous to "boolean", only any text or numeric values can act as a source value (often flags are stored in the database). The format allows you to specify different outputs depending on the value. 283 | Look at this as an associative array in which the key is separated from the value by a dot. Array elements are separated by a vertical line. 284 | 285 | ``` 286 | options:search.On the search|network.In networks 287 | ``` 288 | 289 | ### DateTime 290 | DateTime by default will display as Y-m-d H:i:s. Prefix the value with `datetime:` and then add your datetime format, e.g., 291 | 292 | ``` 293 | datetime:m/d/Y g:i A 294 | ``` 295 | 296 | ### Is Empty 297 | This piggy backs off boolean, but instead of testing for a true or false value, it checks if the value is either null or an empty string. 298 | 299 | ``` 300 | isEmpty:No|Yes 301 | ``` 302 | 303 | This can also accept `%s` if you'd like to output the value, something like the following will display 'Nothing' if the value is empty, or the actual value if something exists: 304 | 305 | ``` 306 | isEmpty:Nothing|%s 307 | ``` 308 | 309 | 310 | ## Load revision history 311 | 312 | To load the revision history for a given model, simply call the `revisionHistory` method on that model, e.g., 313 | 314 | ```php 315 | $article = Article::find($id); 316 | $history = $article->revisionHistory; 317 | ``` 318 | 319 | 320 | ## Displaying history 321 | 322 | For the most part, the revision history will hold enough information to directly output a change history, however in the cases where a foreign key is updated we need to be able to do some mapping and display something nicer than `plan_id changed from 3 to 1`. 323 | 324 | To help with this, there's a few helper methods to display more insightful information, so you can display something like `Chris changed plan from bronze to gold`. 325 | 326 | The above would be the result from this: 327 | 328 | ```php 329 | @foreach($account->revisionHistory as $history ) 330 |
  • {{ $history->userResponsible()->first_name }} changed {{ $history->fieldName() }} from {{ $history->oldValue() }} to {{ $history->newValue() }}
  • 331 | @endforeach 332 | ``` 333 | 334 | If you have enabled revisions of creations as well you can display it like this: 335 | ```php 336 | @foreach($resource->revisionHistory as $history) 337 | @if($history->key == 'created_at' && !$history->old_value) 338 |
  • {{ $history->userResponsible()->first_name }} created this resource at {{ $history->newValue() }}
  • 339 | @else 340 |
  • {{ $history->userResponsible()->first_name }} changed {{ $history->fieldName() }} from {{ $history->oldValue() }} to {{ $history->newValue() }}
  • 341 | @endif 342 | @endforeach 343 | ``` 344 | 345 | ### userResponsible() 346 | 347 | Returns the User that was responsible for making the revision. A user model is returned, or false if there was no user recorded. 348 | 349 | The user model that is loaded depends on what you have set in your `config/auth.php` file for the `model` variable. 350 | 351 | ### fieldName() 352 | 353 | Returns the name of the field that was updated, if the field that was updated was a foreign key (at this stage, it simply looks to see if the field has the suffix of `_id`) then the text before `_id` is returned. e.g., if the field was `plan_id`, then `plan` would be returned. 354 | 355 | > Remember from above, that you can override the output of a field name with the `$revisionFormattedFieldNames` array in your model. 356 | 357 | ### identifiableName() 358 | 359 | This is used when the value (old or new) is the id of a foreign key relationship. 360 | 361 | By default, it simply returns the ID of the model that was updated. It is up to you to override this method in your own models to return something meaningful. e.g., 362 | 363 | ```php 364 | use Venturecraft\Revisionable\Revisionable; 365 | 366 | class Article extends Revisionable 367 | { 368 | public function identifiableName() 369 | { 370 | return $this->title; 371 | } 372 | } 373 | ``` 374 | 375 | ### oldValue() and newValue() 376 | 377 | Get the value of the model before or after the update. If it was a foreign key, identifiableName() is called. 378 | 379 | ### Unknown or invalid foreign keys as revisions 380 | In cases where the old or new version of a value is a foreign key that no longer exists, or indeed was null, there are two variables that you can set in your model to control the output in these situations: 381 | 382 | ```php 383 | protected $revisionNullString = 'nothing'; 384 | protected $revisionUnknownString = 'unknown'; 385 | ``` 386 | 387 | ### disableRevisionField() 388 | Sometimes temporarily disabling a revisionable field can come in handy, if you want to be able to save an update however don't need to keep a record of the changes. 389 | 390 | ```php 391 | $object->disableRevisionField('title'); // Disables title 392 | ``` 393 | 394 | or: 395 | 396 | ```php 397 | $object->disableRevisionField(['title', 'content']); // Disables title and content 398 | ``` 399 | 400 | 401 | ## Contributing 402 | 403 | Contributions are encouraged and welcome; to keep things organised, all bugs and requests should be 404 | opened in the GitHub issues tab for the main project, at [venturecraft/revisionable/issues](https://github.com/venturecraft/revisionable/issues) 405 | 406 | All pull requests should be made to the develop branch, so they can be tested before being merged into the master branch. 407 | 408 | 409 | ## Having troubles? 410 | 411 | If you're having troubles with using this package, odds on someone else has already had the same problem. Two places you can look for common answers to your problems are: 412 | 413 | * [StackOverflow revisionable tag](https://stackoverflow.com/questions/tagged/revisionable?sort=newest&pageSize=50) 414 | * [GitHub Issues](https://github.com/VentureCraft/revisionable/issues) 415 | 416 | > If you do prefer posting your questions to the public on StackOverflow, please use the 'revisionable' tag. 417 | -------------------------------------------------------------------------------- /src/Venturecraft/Revisionable/RevisionableTrait.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | */ 11 | 12 | /** 13 | * Class RevisionableTrait 14 | * @package Venturecraft\Revisionable 15 | */ 16 | trait RevisionableTrait 17 | { 18 | /** 19 | * @var array 20 | */ 21 | private $originalData = array(); 22 | 23 | /** 24 | * @var array 25 | */ 26 | private $updatedData = array(); 27 | 28 | /** 29 | * @var boolean 30 | */ 31 | private $updating = false; 32 | 33 | /** 34 | * @var array 35 | */ 36 | private $dontKeep = array(); 37 | 38 | /** 39 | * @var array 40 | */ 41 | private $doKeep = array(); 42 | 43 | /** 44 | * Keeps the list of values that have been updated 45 | * 46 | * @var array 47 | */ 48 | protected $dirtyData = array(); 49 | 50 | /** 51 | * Ensure that the bootRevisionableTrait is called only 52 | * if the current installation is a laravel 4 installation 53 | * Laravel 5 will call bootRevisionableTrait() automatically 54 | */ 55 | public static function boot() 56 | { 57 | parent::boot(); 58 | 59 | if (!method_exists(get_called_class(), 'bootTraits')) { 60 | static::bootRevisionableTrait(); 61 | } 62 | } 63 | 64 | /** 65 | * Create the event listeners for the saving and saved events 66 | * This lets us save revisions whenever a save is made, no matter the 67 | * http method. 68 | * 69 | */ 70 | public static function bootRevisionableTrait() 71 | { 72 | static::saving(function ($model) { 73 | $model->preSave(); 74 | }); 75 | 76 | static::saved(function ($model) { 77 | $model->postSave(); 78 | }); 79 | 80 | static::created(function ($model) { 81 | $model->postCreate(); 82 | }); 83 | 84 | static::deleted(function ($model) { 85 | $model->preSave(); 86 | $model->postDelete(); 87 | $model->postForceDelete(); 88 | }); 89 | } 90 | 91 | /** 92 | * @return mixed 93 | */ 94 | public function revisionHistory() 95 | { 96 | return $this->morphMany(get_class(Revisionable::newModel()), 'revisionable'); 97 | } 98 | 99 | /** 100 | * Generates a list of the last $limit revisions made to any objects of the class it is being called from. 101 | * 102 | * @param int $limit 103 | * @param string $order 104 | * @return mixed 105 | */ 106 | public static function classRevisionHistory($limit = 100, $order = 'desc') 107 | { 108 | $model = Revisionable::newModel(); 109 | return $model->where('revisionable_type', get_called_class()) 110 | ->orderBy('updated_at', $order)->limit($limit)->get(); 111 | } 112 | 113 | /** 114 | * Invoked before a model is saved. Return false to abort the operation. 115 | * 116 | * @return bool 117 | */ 118 | public function preSave() 119 | { 120 | if (!isset($this->revisionEnabled) || $this->revisionEnabled) { 121 | // if there's no revisionEnabled. Or if there is, if it's true 122 | 123 | $this->originalData = $this->original; 124 | $this->updatedData = $this->attributes; 125 | 126 | // we can only safely compare basic items, 127 | // so for now we drop any object based items, like DateTime 128 | foreach ($this->updatedData as $key => $val) { 129 | $castCheck = ['object', 'array']; 130 | if (isset($this->casts[$key]) && in_array(gettype($val), $castCheck) && in_array($this->casts[$key], $castCheck) && isset($this->originalData[$key])) { 131 | // Sorts the keys of a JSON object due Normalization performed by MySQL 132 | // So it doesn't set false flag if it is changed only order of key or whitespace after comma 133 | 134 | $updatedData = $this->sortJsonKeys(json_decode($this->updatedData[$key], true)); 135 | 136 | $this->updatedData[$key] = json_encode($updatedData); 137 | $this->originalData[$key] = json_encode(json_decode($this->originalData[$key], true)); 138 | } else if (gettype($val) == 'object' && !method_exists($val, '__toString')) { 139 | unset($this->originalData[$key]); 140 | unset($this->updatedData[$key]); 141 | array_push($this->dontKeep, $key); 142 | } 143 | } 144 | 145 | // the below is ugly, for sure, but it's required so we can save the standard model 146 | // then use the keep / dontkeep values for later, in the isRevisionable method 147 | $this->dontKeep = isset($this->dontKeepRevisionOf) ? 148 | array_merge($this->dontKeepRevisionOf, $this->dontKeep) 149 | : $this->dontKeep; 150 | 151 | $this->doKeep = isset($this->keepRevisionOf) ? 152 | array_merge($this->keepRevisionOf, $this->doKeep) 153 | : $this->doKeep; 154 | 155 | unset($this->attributes['dontKeepRevisionOf']); 156 | unset($this->attributes['keepRevisionOf']); 157 | 158 | $this->dirtyData = $this->getDirty(); 159 | $this->updating = $this->exists; 160 | } 161 | } 162 | 163 | 164 | /** 165 | * Called after a model is successfully saved. 166 | * 167 | * @return void 168 | */ 169 | public function postSave() 170 | { 171 | if (isset($this->historyLimit) && $this->revisionHistory()->count() >= $this->historyLimit) { 172 | $LimitReached = true; 173 | } else { 174 | $LimitReached = false; 175 | } 176 | if (isset($this->revisionCleanup)){ 177 | $RevisionCleanup=$this->revisionCleanup; 178 | }else{ 179 | $RevisionCleanup=false; 180 | } 181 | 182 | // check if the model already exists 183 | if (((!isset($this->revisionEnabled) || $this->revisionEnabled) && $this->updating) && (!$LimitReached || $RevisionCleanup)) { 184 | // if it does, it means we're updating 185 | 186 | $changes_to_record = $this->changedRevisionableFields(); 187 | 188 | $revisions = array(); 189 | 190 | foreach ($changes_to_record as $key => $change) { 191 | $original = array( 192 | 'revisionable_type' => $this->getMorphClass(), 193 | 'revisionable_id' => $this->getKey(), 194 | 'key' => $key, 195 | 'old_value' => Arr::get($this->originalData, $key), 196 | 'new_value' => $this->updatedData[$key], 197 | 'user_id' => $this->getSystemUserId(), 198 | 'created_at' => new \DateTime(), 199 | 'updated_at' => new \DateTime(), 200 | ); 201 | 202 | $revisions[] = array_merge($original, $this->getAdditionalFields()); 203 | } 204 | 205 | if (count($revisions) > 0) { 206 | if($LimitReached && $RevisionCleanup){ 207 | $toDelete = $this->revisionHistory()->orderBy('id','asc')->limit(count($revisions))->get(); 208 | foreach($toDelete as $delete){ 209 | $delete->delete(); 210 | } 211 | } 212 | $revision = Revisionable::newModel(); 213 | \DB::table($revision->getTable())->insert($revisions); 214 | \Event::dispatch('revisionable.saved', array('model' => $this, 'revisions' => $revisions)); 215 | } 216 | } 217 | } 218 | 219 | /** 220 | * Called after record successfully created 221 | */ 222 | public function postCreate() 223 | { 224 | 225 | // Check if we should store creations in our revision history 226 | // Set this value to true in your model if you want to 227 | if(empty($this->revisionCreationsEnabled)) 228 | { 229 | // We should not store creations. 230 | return false; 231 | } 232 | 233 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled)) 234 | { 235 | $revisions[] = array( 236 | 'revisionable_type' => $this->getMorphClass(), 237 | 'revisionable_id' => $this->getKey(), 238 | 'key' => self::CREATED_AT, 239 | 'old_value' => null, 240 | 'new_value' => $this->{self::CREATED_AT}, 241 | 'user_id' => $this->getSystemUserId(), 242 | 'created_at' => new \DateTime(), 243 | 'updated_at' => new \DateTime(), 244 | ); 245 | 246 | //Determine if there are any additional fields we'd like to add to our model contained in the config file, and 247 | //get them into an array. 248 | $revisions = array_merge($revisions[0], $this->getAdditionalFields()); 249 | 250 | $revision = Revisionable::newModel(); 251 | \DB::table($revision->getTable())->insert($revisions); 252 | \Event::dispatch('revisionable.created', array('model' => $this, 'revisions' => $revisions)); 253 | } 254 | 255 | } 256 | 257 | /** 258 | * If softdeletes are enabled, store the deleted time 259 | */ 260 | public function postDelete() 261 | { 262 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled) 263 | && $this->isSoftDelete() 264 | && $this->isRevisionable($this->getDeletedAtColumn()) 265 | ) { 266 | $revisions[] = array( 267 | 'revisionable_type' => $this->getMorphClass(), 268 | 'revisionable_id' => $this->getKey(), 269 | 'key' => $this->getDeletedAtColumn(), 270 | 'old_value' => null, 271 | 'new_value' => $this->{$this->getDeletedAtColumn()}, 272 | 'user_id' => $this->getSystemUserId(), 273 | 'created_at' => new \DateTime(), 274 | 'updated_at' => new \DateTime(), 275 | ); 276 | 277 | //Since there is only one revision because it's deleted, let's just merge into revision[0] 278 | $revisions = array_merge($revisions[0], $this->getAdditionalFields()); 279 | 280 | $revision = Revisionable::newModel(); 281 | \DB::table($revision->getTable())->insert($revisions); 282 | \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions)); 283 | } 284 | } 285 | 286 | /** 287 | * If forcedeletes are enabled, set the value created_at of model to null 288 | * 289 | * @return void|bool 290 | */ 291 | public function postForceDelete() 292 | { 293 | if (empty($this->revisionForceDeleteEnabled)) { 294 | return false; 295 | } 296 | 297 | if ((!isset($this->revisionEnabled) || $this->revisionEnabled) 298 | && (($this->isSoftDelete() && $this->isForceDeleting()) || !$this->isSoftDelete())) { 299 | 300 | $revisions[] = array( 301 | 'revisionable_type' => $this->getMorphClass(), 302 | 'revisionable_id' => $this->getKey(), 303 | 'key' => self::CREATED_AT, 304 | 'old_value' => $this->{self::CREATED_AT}, 305 | 'new_value' => null, 306 | 'user_id' => $this->getSystemUserId(), 307 | 'created_at' => new \DateTime(), 308 | 'updated_at' => new \DateTime(), 309 | ); 310 | 311 | $revision = Revisionable::newModel(); 312 | \DB::table($revision->getTable())->insert($revisions); 313 | \Event::dispatch('revisionable.deleted', array('model' => $this, 'revisions' => $revisions)); 314 | } 315 | } 316 | 317 | /** 318 | * Attempt to find the user id of the currently logged in user 319 | * Supports Cartalyst Sentry/Sentinel based authentication, as well as stock Auth 320 | **/ 321 | public function getSystemUserId() 322 | { 323 | try { 324 | if (class_exists($class = '\SleepingOwl\AdminAuth\Facades\AdminAuth') 325 | || class_exists($class = '\Cartalyst\Sentry\Facades\Laravel\Sentry') 326 | || class_exists($class = '\Cartalyst\Sentinel\Laravel\Facades\Sentinel') 327 | ) { 328 | return ($class::check()) ? $class::getUser()->id : null; 329 | } elseif (function_exists('backpack_auth') && backpack_auth()->check()) { 330 | return backpack_user()->id; 331 | } elseif (\Auth::check()) { 332 | return \Auth::user()->getAuthIdentifier(); 333 | } 334 | } catch (\Exception $e) { 335 | return null; 336 | } 337 | 338 | return null; 339 | } 340 | 341 | 342 | public function getAdditionalFields() 343 | { 344 | $additional = []; 345 | //Determine if there are any additional fields we'd like to add to our model contained in the config file, and 346 | //get them into an array. 347 | $fields = config('revisionable.additional_fields', []); 348 | foreach($fields as $field) { 349 | if(Arr::has($this->originalData, $field)) { 350 | $additional[$field] = Arr::get($this->originalData, $field); 351 | } 352 | } 353 | 354 | return $additional; 355 | } 356 | 357 | /** 358 | * Get all of the changes that have been made, that are also supposed 359 | * to have their changes recorded 360 | * 361 | * @return array fields with new data, that should be recorded 362 | */ 363 | private function changedRevisionableFields() 364 | { 365 | $changes_to_record = array(); 366 | foreach ($this->dirtyData as $key => $value) { 367 | // check that the field is revisionable, and double check 368 | // that it's actually new data in case dirty is, well, clean 369 | if ($this->isRevisionable($key) && !is_array($value)) { 370 | if (!array_key_exists($key, $this->originalData) || $this->originalData[$key] != $this->updatedData[$key]) { 371 | $changes_to_record[$key] = $value; 372 | } 373 | } else { 374 | // we don't need these any more, and they could 375 | // contain a lot of data, so lets trash them. 376 | unset($this->updatedData[$key]); 377 | unset($this->originalData[$key]); 378 | } 379 | } 380 | 381 | return $changes_to_record; 382 | } 383 | 384 | /** 385 | * Check if this field should have a revision kept 386 | * 387 | * @param string $key 388 | * 389 | * @return bool 390 | */ 391 | private function isRevisionable($key) 392 | { 393 | 394 | // If the field is explicitly revisionable, then return true. 395 | // If it's explicitly not revisionable, return false. 396 | // Otherwise, if neither condition is met, only return true if 397 | // we aren't specifying revisionable fields. 398 | if (isset($this->doKeep) && in_array($key, $this->doKeep)) { 399 | return true; 400 | } 401 | if (isset($this->dontKeep) && in_array($key, $this->dontKeep)) { 402 | return false; 403 | } 404 | 405 | return empty($this->doKeep); 406 | } 407 | 408 | /** 409 | * Check if soft deletes are currently enabled on this model 410 | * 411 | * @return bool 412 | */ 413 | private function isSoftDelete() 414 | { 415 | // check flag variable used in laravel 4.2+ 416 | if (isset($this->forceDeleting)) { 417 | return !$this->forceDeleting; 418 | } 419 | 420 | // otherwise, look for flag used in older versions 421 | if (isset($this->softDelete)) { 422 | return $this->softDelete; 423 | } 424 | 425 | return false; 426 | } 427 | 428 | /** 429 | * @return mixed 430 | */ 431 | public function getRevisionFormattedFields() 432 | { 433 | return $this->revisionFormattedFields; 434 | } 435 | 436 | /** 437 | * @return mixed 438 | */ 439 | public function getRevisionFormattedFieldNames() 440 | { 441 | return $this->revisionFormattedFieldNames; 442 | } 443 | 444 | /** 445 | * Identifiable Name 446 | * When displaying revision history, when a foreign key is updated 447 | * instead of displaying the ID, you can choose to display a string 448 | * of your choice, just override this method in your model 449 | * By default, it will fall back to the models ID. 450 | * 451 | * @return string an identifying name for the model 452 | */ 453 | public function identifiableName() 454 | { 455 | return $this->getKey(); 456 | } 457 | 458 | /** 459 | * Revision Unknown String 460 | * When displaying revision history, when a foreign key is updated 461 | * instead of displaying the ID, you can choose to display a string 462 | * of your choice, just override this method in your model 463 | * By default, it will fall back to the models ID. 464 | * 465 | * @return string an identifying name for the model 466 | */ 467 | public function getRevisionNullString() 468 | { 469 | return isset($this->revisionNullString) ? $this->revisionNullString : 'nothing'; 470 | } 471 | 472 | /** 473 | * No revision string 474 | * When displaying revision history, if the revisions value 475 | * cant be figured out, this is used instead. 476 | * It can be overridden. 477 | * 478 | * @return string an identifying name for the model 479 | */ 480 | public function getRevisionUnknownString() 481 | { 482 | return isset($this->revisionUnknownString) ? $this->revisionUnknownString : 'unknown'; 483 | } 484 | 485 | /** 486 | * Disable a revisionable field temporarily 487 | * Need to do the adding to array longhanded, as there's a 488 | * PHP bug https://bugs.php.net/bug.php?id=42030 489 | * 490 | * @param mixed $field 491 | * 492 | * @return void 493 | */ 494 | public function disableRevisionField($field) 495 | { 496 | if (!isset($this->dontKeepRevisionOf)) { 497 | $this->dontKeepRevisionOf = array(); 498 | } 499 | if (is_array($field)) { 500 | foreach ($field as $one_field) { 501 | $this->disableRevisionField($one_field); 502 | } 503 | } else { 504 | $donts = $this->dontKeepRevisionOf; 505 | $donts[] = $field; 506 | $this->dontKeepRevisionOf = $donts; 507 | unset($donts); 508 | } 509 | } 510 | 511 | /** 512 | * Sorts the keys of a JSON object 513 | * 514 | * Normalization performed by MySQL and 515 | * discards extra whitespace between keys, values, or elements 516 | * in the original JSON document. 517 | * To make lookups more efficient, it sorts the keys of a JSON object. 518 | * 519 | * @param mixed $attribute 520 | * 521 | * @return mixed 522 | */ 523 | private function sortJsonKeys($attribute) 524 | { 525 | if(empty($attribute)) return $attribute; 526 | 527 | foreach ($attribute as $key=>$value) { 528 | if(is_array($value) || is_object($value)){ 529 | $value = $this->sortJsonKeys($value); 530 | } else { 531 | continue; 532 | } 533 | 534 | ksort($value); 535 | $attribute[$key] = $value; 536 | } 537 | 538 | return $attribute; 539 | } 540 | } 541 | --------------------------------------------------------------------------------