├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── rewind.php ├── database ├── factories │ └── ModelFactory.php └── migrations │ └── create_rewind_versions_table.php.stub ├── resources └── views │ └── .gitkeep ├── src ├── Commands │ └── AddVersionTrackingColumnCommand.php ├── Dto │ └── ApproachPlan.php ├── Enums │ └── ApproachMethod.php ├── Events │ ├── RewindVersionCreated.php │ └── RewindVersionCreating.php ├── Exceptions │ ├── CurrentVersionColumnMissingException.php │ ├── LaravelRewindException.php │ ├── ModelNotRewindableException.php │ └── VersionDoesNotExistException.php ├── Facades │ └── Rewind.php ├── LaravelRewindServiceProvider.php ├── Listeners │ ├── CreateRewindVersion.php │ └── CreateRewindVersionQueued.php ├── Models │ └── RewindVersion.php ├── Services │ ├── ApproachEngine.php │ └── RewindManager.php └── Traits │ └── Rewindable.php └── stubs └── add_current_version_column.php.stub /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-rewind` will be documented in this file. 4 | 5 | ## v0.7.2 - 2025-02-28 6 | 7 | ### What's Changed 8 | 9 | * Append version 12.0 to the package ‘illuminate/contracts’. by @fdjkgh580 in https://github.com/avocet-shores/laravel-rewind/pull/23 10 | 11 | ### New Contributors 12 | 13 | * @fdjkgh580 made their first contribution in https://github.com/avocet-shores/laravel-rewind/pull/23 14 | 15 | **Full Changelog**: https://github.com/avocet-shores/laravel-rewind/compare/v0.7.1...v0.7.2 16 | 17 | ## v0.7.1 - 2025-02-13 18 | 19 | ### What's Changed 20 | 21 | #### Bug Fixes and Improvements 22 | 23 | * Fix Delete Bug and Add Soft Delete Tracking by @jared-cannon in https://github.com/avocet-shores/laravel-rewind/pull/20 24 | 25 | #### Docs 26 | 27 | * docs: Fix Rewindable trait on model by @nilshee in https://github.com/avocet-shores/laravel-rewind/pull/19 28 | 29 | ### New Contributors 30 | 31 | * @nilshee made their first contribution in https://github.com/avocet-shores/laravel-rewind/pull/19 32 | 33 | **Full Changelog**: https://github.com/avocet-shores/laravel-rewind/compare/v0.7.0...v0.7.1 34 | 35 | ## v0.7.0 - 2025-01-24 36 | 37 | ### What's Changed 38 | 39 | * Update Morph Relationship by @jared-cannon in https://github.com/avocet-shores/laravel-rewind/pull/12 40 | * Concurrency config enhancements and current_version column requirements by @jared-cannon in https://github.com/avocet-shores/laravel-rewind/pull/9 41 | 42 | **Full Changelog**: https://github.com/avocet-shores/laravel-rewind/compare/v0.6.0...v0.7.0 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Avocet Shores 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Rewind 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/avocet-shores/laravel-rewind.svg?style=flat-square)](https://packagist.org/packages/avocet-shores/laravel-rewind) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/avocet-shores/laravel-rewind/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/avocet-shores/laravel-rewind/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![Coverage Status](https://img.shields.io/codecov/c/github/avocet-shores/laravel-rewind?style=flat-square)](https://app.codecov.io/gh/avocet-shores/laravel-rewind/) 6 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/avocet-shores/laravel-rewind/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/avocet-shores/laravel-rewind/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/avocet-shores/laravel-rewind.svg?style=flat-square)](https://packagist.org/packages/avocet-shores/laravel-rewind) 8 | 9 | Laravel Rewind is a powerful, easy-to-use versioning package for your Eloquent models. 10 | 11 | Imagine you have a Post model and want to track how it evolves over time: 12 | 13 | ```php 14 | use AvocetShores\LaravelRewind\Facades\Rewind; 15 | 16 | // Previous title: 'Old Title' 17 | $post->title = 'Updated Title'; 18 | $post->save(); 19 | 20 | // Title goes back to 'Old Title' 21 | Rewind::rewind($post); 22 | 23 | // Title goes forward to 'Updated Title' 24 | Rewind::fastForward($post); 25 | ``` 26 | 27 | You can also view a list of previous versions of a model, see what changed, and even jump to a specific version. 28 | 29 | ```php 30 | $versions = $post->versions; 31 | 32 | Rewind::goTo($post, $versions->first()->version); 33 | ``` 34 | 35 | ## How It Works 36 | 37 | Under the hood, Rewind stores a combination of partial diffs and full snapshots of your model’s data. The interval between 38 | full snapshots is determined by the `rewind.snapshot_interval` config value. This provides you with a customizable trade-off 39 | between storage cost and performance. Rewind's engine will automatically determine the shortest path between your current 40 | version, available snapshots, and your target. 41 | 42 | ### How does Rewind handle history? 43 | 44 | Rewind maintains a simple linear history of your model’s changes, but what exactly happens when you update a model 45 | while on an older version? Let's take a look: 46 | 47 | 1. You create a new version of your model: 48 | 49 | ```php 50 | // Previous title: 'Old Title' 51 | $post->title = 'New Title'; 52 | $post->save(); 53 | ``` 54 | 55 | 2. You rewind to a previous version: 56 | 57 | ```php 58 | // Title goes back to 'Old Title' 59 | Rewind::rewind($post); 60 | ``` 61 | 62 | 3. You update the model *while on an older version*: 63 | 64 | ```php 65 | $post->title = 'Rewind is Awesome!'; 66 | $post->save(); 67 | ``` 68 | 69 | 4. What version are we on now, and what data is in it? 70 | 71 | In order to maintain a linear, non-destructive history, Rewind uses the previous head version as the 72 | content of the `old_values` for the new version you just created. It also creates a full snapshot of the model’s 73 | current state and designates it as the new head. So the current version in our above example looks like this: 74 | 75 | ```php 76 | [ 77 | 'version' => 3, 78 | 'old_values' => [ 79 | 'title' => 'New Title', // Note: This is the title from v2, not v1 80 | // Other attributes... 81 | ], 82 | 'new_values' => [ 83 | 'title' => 'Rewind is Awesome!', 84 | // Other attributes... 85 | ], 86 | ] 87 | ``` 88 | 89 | In other words, your model's history will always look like it updated from the previous head version. This way, you can always see 90 | what changed between versions, even if you jump back and forth in time. And you can always revert to a previous version without fear of losing data. 91 | 92 | ### Thread Safety 93 | 94 | Rewind is designed with thread-safety in mind. Before creating a new version, Rewind must acquire a cache lock for that specific record. This ensures that only one 95 | process can create a new version at a time. If a process is unable to acquire the lock, it will wait for a set period of time before throwing an exception. 96 | 97 | ## Installation 98 | 99 | You can install the package via composer: 100 | 101 | ```bash 102 | composer require avocet-shores/laravel-rewind 103 | ``` 104 | 105 | You can publish and run the migrations, and publish the config, with: 106 | 107 | ```bash 108 | php artisan vendor:publish --provider="AvocetShores\LaravelRewind\LaravelRewindServiceProvider" 109 | 110 | php artisan migrate 111 | ``` 112 | 113 | ## Getting Started 114 | 115 | To enable version tracking on a model, follow these two steps: 116 | 117 | ### 1. Add the Rewindable trait to your Eloquent model: 118 | 119 | ```php 120 | use AvocetShores\LaravelRewind\Traits\Rewindable; 121 | 122 | class Post extends Model 123 | { 124 | use Rewindable; 125 | } 126 | ``` 127 | 128 | ### 2. Add the `current_version` column to your model’s table: 129 | 130 | In order to function properly, Rewind needs to track which version your model is currently on. You can use our 131 | convenient artisan command to generate a migration to do just that: 132 | 133 | ```bash 134 | php artisan rewind:add-version 135 | ``` 136 | 137 | - This command will prompt you for the table name you wish to extend. 138 | - After providing the table name, it creates a migration file that will add a current_version column to that table. 139 | - Run `php artisan migrate` to apply it. 140 | - Once this column is in place, the RewindManager will automatically manage your model’s current_version. 141 | 142 | That’s it! Now your model’s changes are recorded in the `rewind_versions` table, and you can jump backwards or forwards in time. 143 | 144 | ## Usage 145 | 146 | ### Creating/Updating a Model 147 | 148 | ```php 149 | $post = Post::find(1); 150 | $post->title = "New Title"; 151 | $post->save(); 152 | // A new version is automatically created 153 | ``` 154 | 155 | ### Using the Rewind Facade 156 | 157 | ```php 158 | use AvocetShores\LaravelRewind\Facades\Rewind; 159 | 160 | // Rewind two versions back 161 | Rewind::rewind($post, 2); 162 | 163 | // Fast-forward one version 164 | Rewind::fastForward($post); 165 | 166 | // Jump directly to a specific version 167 | Rewind::goTo($post, 5); 168 | ``` 169 | 170 | ### Excluding attributes from versioning 171 | 172 | If you have attributes that you don't want to track, you can exclude them by adding an `excludedFromVersioning` 173 | method to your model: 174 | 175 | ```php 176 | public static function excludedFromVersioning(): array 177 | { 178 | return ['password', 'api_token']; 179 | } 180 | ``` 181 | 182 | ### Build a specific version's attributes 183 | 184 | Because Rewind stores a combination of partial diffs and snapshots, there's no guarantee a RewindVersion contains 185 | all the data for a version. However, the getVersionAttributes method will build and return a complete set of attributes 186 | for a specific version. 187 | 188 | ```php 189 | $attributes = Rewind::getVersionAttributes($post, 7); 190 | ``` 191 | 192 | 193 | ### Clone a Model at a specific version 194 | 195 | You can clone a model by using the `cloneModel` function. This will create a new model and fill it with the attributes from the specified version. 196 | 197 | ```php 198 | $clonedPost = Rewind::cloneModel($post, 5); 199 | ``` 200 | 201 | ### Initialize a v1 on your model without making any changes 202 | 203 | If you have an existing model and want to add a v1 record without making any changes, you can call the `initVersion` function directly from your Rewindable model. 204 | 205 | ```php 206 | $post->initVersion(); 207 | ``` 208 | 209 | ## Testing 210 | 211 | ```bash 212 | composer test 213 | ``` 214 | 215 | ## Changelog 216 | 217 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 218 | 219 | ## Contributing 220 | 221 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 222 | 223 | ## Security Vulnerabilities 224 | 225 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 226 | 227 | ## Credits 228 | 229 | - [Jared Cannon](https://github.com/jared-cannon) 230 | - [All Contributors](../../contributors) 231 | 232 | ## License 233 | 234 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 235 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "avocet-shores/laravel-rewind", 3 | "description": "Laravel Rewind is a powerful, easy-to-use versioning package for your Eloquent models.", 4 | "version": "0.7.2", 5 | "keywords": [ 6 | "Avocet Shores", 7 | "laravel", 8 | "laravel-rewind", 9 | "rewind", 10 | "versioning" 11 | ], 12 | "homepage": "https://github.com/avocet-shores/laravel-rewind", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "jared.cannon", 17 | "email": "jaredcannon9@gmail.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.3", 23 | "spatie/laravel-package-tools": "^1.16", 24 | "illuminate/contracts": "^10.0||^11.0||^12.0" 25 | }, 26 | "require-dev": { 27 | "laravel/pint": "^1.14", 28 | "nunomaduro/collision": "^8.1.1||^7.10.0", 29 | "larastan/larastan": "^2.9", 30 | "orchestra/testbench": "^9.0.0||^8.22.0", 31 | "pestphp/pest": "^3.0", 32 | "pestphp/pest-plugin-arch": "^3.0", 33 | "pestphp/pest-plugin-laravel": "^3.0", 34 | "phpstan/extension-installer": "^1.3", 35 | "phpstan/phpstan-deprecation-rules": "^1.1", 36 | "phpstan/phpstan-phpunit": "^1.3", 37 | "spatie/laravel-ray": "^1.35" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "AvocetShores\\LaravelRewind\\": "src/", 42 | "AvocetShores\\LaravelRewind\\Database\\Factories\\": "database/factories/" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "AvocetShores\\LaravelRewind\\Tests\\": "tests/", 48 | "Workbench\\App\\": "workbench/app/" 49 | } 50 | }, 51 | "scripts": { 52 | "post-autoload-dump": "@composer run prepare", 53 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 54 | "analyse": "vendor/bin/phpstan analyse", 55 | "test": "vendor/bin/pest", 56 | "test-coverage": "vendor/bin/pest --coverage", 57 | "format": "vendor/bin/pint" 58 | }, 59 | "config": { 60 | "sort-packages": true, 61 | "allow-plugins": { 62 | "pestphp/pest-plugin": true, 63 | "phpstan/extension-installer": true 64 | } 65 | }, 66 | "extra": { 67 | "laravel": { 68 | "providers": [ 69 | "AvocetShores\\LaravelRewind\\LaravelRewindServiceProvider" 70 | ], 71 | "aliases": { 72 | "LaravelRewind": "Rewind" 73 | } 74 | } 75 | }, 76 | "minimum-stability": "dev", 77 | "prefer-stable": true 78 | } 79 | -------------------------------------------------------------------------------- /config/rewind.php: -------------------------------------------------------------------------------- 1 | env('LARAVEL_REWIND_TABLE', 'rewind_versions'), 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Rewind Versions Table User ID Column 22 | |-------------------------------------------------------------------------- 23 | | 24 | | Here you may define the name of the column that stores the user ID. 25 | | By default, it is set to "user_id". You may override it via an 26 | | environment variable or update this value directly. 27 | | 28 | */ 29 | 30 | 'user_id_column' => env('LARAVEL_REWIND_USER_ID_COLUMN', 'user_id'), 31 | 32 | /* 33 | |-------------------------------------------------------------------------- 34 | | User Model 35 | |-------------------------------------------------------------------------- 36 | | 37 | | Here you may define the model that represents the user table. 38 | | By default, it is set to "App\Models\User". You may override it 39 | | via an environment variable or update this value directly. 40 | | 41 | */ 42 | 43 | 'user_model' => env('LARAVEL_REWIND_USER_MODEL', 'App\Models\User'), 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Rewind Versions Table Connection 48 | |-------------------------------------------------------------------------- 49 | | 50 | | Here you may define the connection that the versions table uses. 51 | | By default, it is set to "null" which uses the default connection. 52 | | You may override it via an environment variable or update this value directly. 53 | | 54 | */ 55 | 56 | 'database_connection' => env('LARAVEL_REWIND_CONNECTION'), 57 | 58 | /* 59 | |-------------------------------------------------------------------------- 60 | | Track Authenticated User 61 | |-------------------------------------------------------------------------- 62 | | 63 | | If true, the package will automatically store the currently authenticated 64 | | user's ID in the versions table (when available). If your application 65 | | doesn't track or need user IDs, set this value to false. 66 | | 67 | */ 68 | 69 | 'track_user' => true, 70 | 71 | /* 72 | |-------------------------------------------------------------------------- 73 | | Snapshot Interval 74 | |-------------------------------------------------------------------------- 75 | | 76 | | Here you may define the interval between versions that should be stored 77 | | as a full snapshot. By default, it is set to 10, but you may adjust 78 | | this value to suit your application's needs. Higher values reduce 79 | | the amount of data stored at the cost of longer traversal times. 80 | | 81 | */ 82 | 83 | 'snapshot_interval' => env('LARAVEL_REWIND_SNAPSHOT_INTERVAL', 10), 84 | 85 | /* 86 | |-------------------------------------------------------------------------- 87 | | Listener Should Queue 88 | |-------------------------------------------------------------------------- 89 | | 90 | | If true, the package will queue the Create Rewind Version listener that handles the RewindVersionCreating event. 91 | | If false, the listener will run synchronously. 92 | | 93 | */ 94 | 95 | 'listener_should_queue' => env('LARAVEL_REWIND_LISTENER_SHOULD_QUEUE', false), 96 | 97 | /* 98 | |-------------------------------------------------------------------------- 99 | | Concurrency Settings 100 | |-------------------------------------------------------------------------- 101 | | 102 | | Define how long to wait (in seconds) for lock acquisition before timing out, 103 | | and how long the lock should remain valid if the process unexpectedly ends. 104 | */ 105 | 106 | 'lock_wait' => env('REWIND_LOCK_WAIT', 20), 107 | 'lock_timeout' => env('REWIND_LOCK_TIMEOUT', 10), 108 | ]; 109 | -------------------------------------------------------------------------------- /database/factories/ModelFactory.php: -------------------------------------------------------------------------------- 1 | create(config('rewind.table_name'), function (Blueprint $table) { 12 | $table->id(); 13 | $table->string('model_type'); 14 | $table->unsignedBigInteger('model_id'); 15 | $table->text('old_values')->nullable(); 16 | $table->text('new_values')->nullable(); 17 | $table->unsignedBigInteger('version'); 18 | $table->boolean('is_snapshot')->default(false); 19 | $table->unsignedBigInteger('user_id')->nullable(); 20 | $table->timestamps(); 21 | 22 | $table->index(['model_type', 'model_id']); 23 | }); 24 | } 25 | 26 | public function down(): void 27 | { 28 | Schema::dropIfExists(config('rewind.table_name')); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avocet-shores/laravel-rewind/3d634e0077694724503957476504755e7a77a149/resources/views/.gitkeep -------------------------------------------------------------------------------- /src/Commands/AddVersionTrackingColumnCommand.php: -------------------------------------------------------------------------------- 1 | argument('table'); 20 | 21 | // If no table name provided, prompt user 22 | if (! $table) { 23 | $table = $this->ask('Which table do you want to add the current_version column to?'); 24 | } 25 | 26 | if (! $table) { 27 | $this->error('A table name is required.'); 28 | 29 | return 1; 30 | } 31 | 32 | // Determine the class name for the migration 33 | $className = 'AddCurrentVersionTo'.Str::studly($table).'Table'; 34 | 35 | // Build the filename 36 | // e.g. 2023_01_01_000000_add_current_version_to_posts_table.php 37 | $timestamp = date('Y_m_d_His'); 38 | $fileName = $timestamp.'_add_current_version_to_'.$table.'_table.php'; 39 | $fullPath = database_path('migrations/'.$fileName); 40 | 41 | // Read the stub and replace placeholders 42 | $stubContent = (new Filesystem)->get($this->stubPath); 43 | 44 | $stubContent = str_replace( 45 | ['DummyClass', 'DummyTable'], 46 | [$className, $table], 47 | $stubContent 48 | ); 49 | 50 | // Write the final migration file 51 | (new Filesystem)->put($fullPath, $stubContent); 52 | 53 | $this->info("Migration created: {$fileName}"); 54 | $this->info("Don't forget to run 'php artisan migrate'!"); 55 | 56 | return 0; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Dto/ApproachPlan.php: -------------------------------------------------------------------------------- 1 | method = $method; 19 | $this->cost = $cost; 20 | $this->snapshot = $snapshot; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Enums/ApproachMethod.php: -------------------------------------------------------------------------------- 1 | getTable())); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Exceptions/LaravelRewindException.php: -------------------------------------------------------------------------------- 1 | name('laravel-rewind') 20 | ->hasConfigFile() 21 | ->hasMigration('create_rewind_versions_table') 22 | ->hasCommand(AddVersionTrackingColumnCommand::class); 23 | } 24 | 25 | public function registeringPackage(): void 26 | { 27 | $this->app->bind('laravel-rewind-manager', RewindManager::class); 28 | } 29 | 30 | public function bootingPackage(): void 31 | { 32 | $async = config('rewind.listener_should_queue', false); 33 | 34 | if ($async) { 35 | Event::listen( 36 | RewindVersionCreating::class, 37 | CreateRewindVersionQueued::class 38 | ); 39 | } else { 40 | Event::listen( 41 | RewindVersionCreating::class, 42 | CreateRewindVersion::class 43 | ); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Listeners/CreateRewindVersion.php: -------------------------------------------------------------------------------- 1 | model; 21 | 22 | $lock = cache()->lock( 23 | sprintf('laravel-rewind-version-lock-%s-%s', $model->getTable(), $model->getKey()), 24 | config('rewind.lock_timeout', 10) 25 | ); 26 | 27 | try { 28 | $lock->block(config('rewind.lock_wait', 20)); 29 | 30 | // Re-check that something is dirty (edge case: might be no changes after all) 31 | $dirty = $model->getDirty(); 32 | if (empty($dirty) && ! $model->wasRecentlyCreated && $model->exists) { 33 | return; 34 | } 35 | 36 | // Determine the new version number 37 | $nextVersion = ($model->versions()->max('version') ?? 0) + 1; 38 | 39 | $oldValues = []; 40 | $newValues = []; 41 | 42 | // If our current version is not the head, we need to rebuild the head record, then store all of its trackable attributes as old_values. 43 | // We then store the new values as the current model attributes, and set it to be a snapshot 44 | $isSnapshot = false; 45 | if ($this->isNotHead($model, $nextVersion)) { 46 | $isSnapshot = true; 47 | $oldValues = $this->rebuildHeadVersion($model); 48 | } 49 | 50 | $attributesToTrack = $this->computeTrackableAttributes($model); 51 | 52 | foreach ($attributesToTrack as $attribute) { 53 | // if we forced a rebuild, oldValues might already contain the old attribute 54 | $originalValue = array_key_exists($attribute, $oldValues) 55 | ? $oldValues[$attribute] 56 | : $model->getOriginal($attribute); 57 | 58 | if ( 59 | ($model->wasRecentlyCreated && empty($originalValue)) 60 | || ! $model->exists 61 | || array_key_exists($attribute, $dirty) 62 | ) { 63 | $oldValues[$attribute] = $this->handleDateAttribute($originalValue); 64 | $newValues[$attribute] = $this->handleDateAttribute($model->getAttribute($attribute)); 65 | } 66 | } 67 | 68 | // If there's truly nothing to store, bail 69 | if (count($oldValues) === 0 && count($newValues) === 0) { 70 | return; 71 | } 72 | 73 | // Check if the snapshot interval triggers a mandatory snapshot 74 | $interval = config('rewind.snapshot_interval', 10); 75 | if (! $isSnapshot) { 76 | $isSnapshot = ($nextVersion % $interval === 0) || $nextVersion === 1; 77 | } 78 | 79 | if ($isSnapshot) { 80 | // We'll store a full snapshot of trackable attributes 81 | $allAttributes = $model->getAttributes(); 82 | $newValues = Arr::only($allAttributes, $attributesToTrack); 83 | } 84 | 85 | // Create the RewindVersion record 86 | $rewindVersion = RewindVersion::create([ 87 | 'model_type' => $model->getMorphClass(), 88 | 'model_id' => $model->getKey(), 89 | 'version' => $nextVersion, 90 | config('rewind.user_id_column') => $model->getRewindTrackUser(), 91 | 'old_values' => $oldValues ?: null, 92 | 'new_values' => $newValues ?: null, 93 | 'is_snapshot' => $isSnapshot, 94 | ]); 95 | 96 | // Update the model's current_version 97 | if ($this->modelHasCurrentVersionColumn($model)) { 98 | $model->disableRewindEvents(); 99 | 100 | $model->forceFill([ 101 | 'current_version' => $nextVersion, 102 | ])->save(); 103 | 104 | $model->enableRewindEvents(); 105 | } 106 | 107 | // Fire the "RewindVersionCreated" event 108 | event(new RewindVersionCreated($model, $rewindVersion)); 109 | 110 | } catch (LockTimeoutException) { 111 | // If we cannot acquire a lock, something is most likely wrong with the environment 112 | $this->handleLockTimeoutException($model); 113 | 114 | return; 115 | } finally { 116 | $lock->release(); 117 | } 118 | } 119 | 120 | protected function handleLockTimeoutException($model): void 121 | { 122 | // Just log for now, but need to consider remediation options for this edge case. 123 | Log::error(sprintf( 124 | 'Failed to acquire lock for RewindVersion creation on %s:%s, your versions may be out of sync.', 125 | get_class($model), 126 | $model->getKey(), 127 | ), [ 128 | 'model' => get_class($model), 129 | 'model_key' => $model->getKey(), 130 | 'changes' => $model->getDirty(), 131 | ]); 132 | } 133 | 134 | protected function rebuildHeadVersion($model): array 135 | { 136 | $data = []; 137 | $lastSnapshot = $model->versions() 138 | ->where('is_snapshot', true) 139 | ->latest('version') 140 | ->first(); 141 | 142 | if ($lastSnapshot) { 143 | $data = $lastSnapshot->new_values; 144 | } 145 | 146 | // Loop through all versions since the last snapshot 147 | $model->versions() 148 | ->where('version', '>', $lastSnapshot?->version ?? 0) 149 | ->orderBy('version') 150 | ->each(function ($version) use (&$data) { 151 | $data = array_merge($data, $version->new_values); 152 | }); 153 | 154 | return $data; 155 | } 156 | 157 | protected function computeTrackableAttributes($model): array 158 | { 159 | $attributes = array_keys(Arr::except( 160 | $model->getAttributes(), 161 | $model->getExcludedRewindableAttributes() 162 | )); 163 | 164 | // Ensure we track soft deletes 165 | if ($model->hasSoftDeletes()) { 166 | $attributes[] = $model->getDeletedAtColumn(); 167 | } 168 | 169 | return array_unique($attributes); 170 | } 171 | 172 | protected function handleDateAttribute($value): mixed 173 | { 174 | return $value instanceof DateTimeInterface 175 | ? $value->format('Y-m-d H:i:s') 176 | : $value; 177 | } 178 | 179 | protected function modelHasCurrentVersionColumn($model): bool 180 | { 181 | return $model->getConnection() 182 | ->getSchemaBuilder() 183 | ->hasColumn($model->getTable(), 'current_version'); 184 | } 185 | 186 | protected function isNotHead($model, int $nextVersion): bool 187 | { 188 | return $model->current_version && $model->current_version !== ($nextVersion - 1); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Listeners/CreateRewindVersionQueued.php: -------------------------------------------------------------------------------- 1 | 'array', 33 | 'new_values' => 'array', 34 | 'version' => 'integer', 35 | 'is_snapshot' => 'boolean', 36 | ]; 37 | 38 | /** 39 | * Dynamically set the table name from config in the constructor. 40 | */ 41 | public function __construct(array $attributes = []) 42 | { 43 | if (! isset($this->connection)) { 44 | $this->setConnection(config('rewind.database_connection')); 45 | } 46 | 47 | if (! isset($this->table)) { 48 | $this->setTable(config('rewind.table_name')); 49 | } 50 | 51 | $this->fillable[] = config('rewind.user_id_column'); 52 | $this->casts[config('rewind.user_id_column')] = 'integer'; 53 | 54 | parent::__construct($attributes); 55 | } 56 | 57 | /** 58 | * Optional relationship to the user who made the change (if user tracking is enabled). 59 | */ 60 | public function user(): BelongsTo 61 | { 62 | // Update this to reference your actual User model namespace if needed. 63 | return $this->belongsTo( 64 | config('rewind.user_model'), 65 | config('rewind.user_id_column') 66 | ); 67 | } 68 | 69 | /** 70 | * Get the model that this version belongs to. 71 | */ 72 | public function model(): MorphTo 73 | { 74 | return $this->morphTo(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Services/ApproachEngine.php: -------------------------------------------------------------------------------- 1 | currentVersion). 14 | * 3) Jumping to the nearest snapshot behind/above the target and replaying diffs. 15 | * 4) Similar logic for an overshoot snapshot, then stepping backward if that is somehow shorter. 16 | * 17 | * This returns an ApproachPlan describing the best approach and the intermediate snapshot (if any). 18 | */ 19 | public function run($model, int $currentVersion, int $targetVersion): ApproachPlan 20 | { 21 | // If currentVersion == targetVersion, there’s nothing to do. 22 | if ($currentVersion === $targetVersion) { 23 | return new ApproachPlan( 24 | method: ApproachMethod::None, 25 | cost: 0, 26 | ); 27 | } 28 | 29 | $model->load('versions'); 30 | 31 | // Count partial diffs for direct forward/backward from currentVersion. 32 | $directCost = $this->countPartialDiffs($model, min($currentVersion, $targetVersion), max($currentVersion, $targetVersion)); 33 | 34 | // Find nearest snapshot behind (or equal to) the target if we want to replay forward from a snapshot. 35 | $snapshotBehind = $this->findNearestSnapshotBehind($model, $targetVersion); 36 | $snapshotBehindCost = null; 37 | if ($snapshotBehind) { 38 | $snapshotVersion = $snapshotBehind->version; 39 | // Jumping to the snapshot counts as 1 step/cost 40 | $snapshotBehindCost = 1 + $this->countPartialDiffs($model, $snapshotVersion, $targetVersion); 41 | } 42 | 43 | // Find nearest snapshot ahead of the target to overshoot and then step backward. 44 | $snapshotAhead = $this->findNearestSnapshotAhead($model, $targetVersion); 45 | $snapshotAheadCost = null; 46 | if ($snapshotAhead) { 47 | $snapshotAheadCost = 1 + $this->countPartialDiffs($model, $targetVersion, $snapshotAhead->version); 48 | } 49 | 50 | // Build a small array of potential approaches 51 | $candidates = []; 52 | 53 | // Direct approach 54 | $candidates[] = new ApproachPlan( 55 | method: ApproachMethod::Direct, 56 | cost: $directCost, 57 | ); 58 | 59 | // Forward from snapshotBehind 60 | if ($snapshotBehindCost !== null) { 61 | $candidates[] = new ApproachPlan( 62 | method: ApproachMethod::From_Snapshot, 63 | cost: $snapshotBehindCost, 64 | snapshot: $snapshotBehind, 65 | ); 66 | } 67 | 68 | // Backward from snapshotAhead 69 | if ($snapshotAheadCost !== null) { 70 | $candidates[] = new ApproachPlan( 71 | method: ApproachMethod::From_Snapshot, 72 | cost: $snapshotAheadCost, 73 | snapshot: $snapshotAhead, 74 | ); 75 | } 76 | 77 | // Pick the lowest cost 78 | return collect($candidates)->sortBy('cost')->first(); 79 | } 80 | 81 | /** 82 | * Count how many partial diffs lie strictly between $fromVersion and $toVersion (inclusive of $toVersion). 83 | */ 84 | protected function countPartialDiffs($model, int $fromVersion, int $toVersion): int 85 | { 86 | if ($toVersion <= $fromVersion) { 87 | return 0; 88 | } 89 | 90 | return $model->versions 91 | ->where('version', '>', $fromVersion) 92 | ->where('version', '<=', $toVersion) 93 | ->count(); 94 | } 95 | 96 | /** 97 | * Find the closest snapshot at or below $version. 98 | */ 99 | protected function findNearestSnapshotBehind($model, int $version) 100 | { 101 | return $model->versions 102 | ->where('is_snapshot', true) 103 | ->where('version', '<=', $version) 104 | ->sortByDesc('version') 105 | ->first(); 106 | } 107 | 108 | /** 109 | * Find the closest snapshot at or above $version. 110 | */ 111 | protected function findNearestSnapshotAhead($model, int $version) 112 | { 113 | return $model->versions 114 | ->where('is_snapshot', true) 115 | ->where('version', '>=', $version) 116 | ->sortBy('version') 117 | ->first(); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Services/RewindManager.php: -------------------------------------------------------------------------------- 1 | assertRewindable($model); 31 | 32 | $targetVersion = $this->determineCurrentVersion($model) - $steps; 33 | 34 | try { 35 | $this->goTo($model, $targetVersion); 36 | } catch (VersionDoesNotExistException) { 37 | // If the target version doesn't exist, just go to the lowest version 38 | $this->goTo($model, $model->versions->min('version')); 39 | } 40 | } 41 | 42 | /** 43 | * Fast-forward by a specified number of steps. 44 | * 45 | * @throws LaravelRewindException 46 | */ 47 | public function fastForward($model, int $steps = 1): void 48 | { 49 | $this->assertRewindable($model); 50 | 51 | $targetVersion = $this->determineCurrentVersion($model) + $steps; 52 | 53 | try { 54 | $this->goTo($model, $targetVersion); 55 | } catch (VersionDoesNotExistException) { 56 | // If the target version doesn't exist, just go to the highest version 57 | $this->goTo($model, $model->versions->max('version')); 58 | } 59 | } 60 | 61 | /** 62 | * Jump directly to a specified version. 63 | * 64 | * @throws ModelNotRewindableException 65 | * @throws VersionDoesNotExistException 66 | * @throws CurrentVersionColumnMissingException 67 | */ 68 | public function goTo($model, int $targetVersion): void 69 | { 70 | $this->assertRewindable($model); 71 | $this->eagerLoadVersions($model); 72 | 73 | // Validate the target version 74 | $targetModel = $model->versions->where('version', $targetVersion)->first(); 75 | if (! $targetModel) { 76 | throw new VersionDoesNotExistException('The specified version does not exist.'); 77 | } 78 | 79 | $model->fill( 80 | $this->buildAttributesForVersion($model, $targetVersion) 81 | ); 82 | 83 | $this->updateModelVersionAndSave($model, $targetVersion); 84 | } 85 | 86 | /** 87 | * Replicates the given model and fills it with the attributes from the specified version. 88 | * 89 | * @throws LaravelRewindException 90 | */ 91 | public function cloneModel(Model $model, int $targetVersion): Model 92 | { 93 | $this->assertRewindable($model); 94 | $this->eagerLoadVersions($model); 95 | 96 | $attributes = $this->buildAttributesForVersion($model, $targetVersion); 97 | 98 | $newModel = $model->replicate( 99 | except: ['current_version'] 100 | ); 101 | $newModel->fill($attributes); 102 | $newModel->save(); 103 | 104 | return $newModel; 105 | } 106 | 107 | /** 108 | * @throws LaravelRewindException 109 | */ 110 | public function getVersionAttributes(Model $model, int $targetVersion): array 111 | { 112 | $this->assertRewindable($model); 113 | $this->eagerLoadVersions($model); 114 | 115 | return $this->buildAttributesForVersion($model, $targetVersion); 116 | } 117 | 118 | /** 119 | * Build an array of attributes representing the given version 120 | */ 121 | protected function buildAttributesForVersion($model, int $targetVersion): array 122 | { 123 | $model->load('versions'); 124 | $currentVersion = $this->determineCurrentVersion($model); 125 | 126 | // First, determine the fastest approach 127 | $approach = $this->approachEngine->run($model, $currentVersion, $targetVersion); 128 | 129 | return match ($approach->method) { 130 | ApproachMethod::None => $model->toArray(), 131 | ApproachMethod::Direct => $this->buildFromDiffs( 132 | model: $model, 133 | currentVersion: $currentVersion, 134 | targetVersion: $targetVersion 135 | ), 136 | ApproachMethod::From_Snapshot => $this->buildFromDiffs( 137 | model: $model, 138 | currentVersion: $approach->snapshot->version, 139 | targetVersion: $targetVersion, 140 | snapshot: $approach->snapshot 141 | ), 142 | }; 143 | } 144 | 145 | protected function buildFromDiffs($model, int $currentVersion, int $targetVersion, ?RewindVersion $snapshot = null): array 146 | { 147 | $attributes = is_null($snapshot) ? 148 | $model->attributesToArray() : 149 | $snapshot->new_values ?? []; 150 | 151 | // Remove any attributes that are excluded 152 | $attributes = Arr::except($attributes, $model->getExcludedRewindableAttributes()); 153 | 154 | if ($currentVersion > $targetVersion) { 155 | // Step downward from currentVersion until targetVersion 156 | for ($ver = $currentVersion; $ver > $targetVersion; $ver--) { 157 | $versionRec = $model->versions 158 | ->where('version', $ver) 159 | ->first(); 160 | 161 | // If there's no partial diff for $ver (e.g. it doesn't exist), skip 162 | if (! $versionRec) { 163 | continue; 164 | } 165 | 166 | // Reverse the partial diff by applying "old_values" 167 | $attributes = array_merge($attributes, $versionRec->old_values); 168 | } 169 | } else { 170 | // Step upward from currentVersion+1 until targetVersion 171 | for ($ver = $currentVersion + 1; $ver <= $targetVersion; $ver++) { 172 | $versionRec = $model->versions 173 | ->where('version', $ver) 174 | ->first(); 175 | 176 | // If there's no partial diff for $ver (e.g. if it was a snapshot or doesn't exist), skip 177 | if (! $versionRec) { 178 | continue; 179 | } 180 | 181 | // Apply the partial diff 182 | $attributes = array_merge($attributes, $versionRec->new_values); 183 | } 184 | } 185 | 186 | return $attributes; 187 | } 188 | 189 | /** 190 | * Update the model's current_version to the specified version without triggering Rewind events 191 | */ 192 | protected function updateModelVersionAndSave($model, int $version): void 193 | { 194 | if (! $this->modelHasCurrentVersionColumn($model)) { 195 | return; 196 | } 197 | 198 | $model->disableRewindEvents(); 199 | 200 | $model->forceFill([ 201 | 'current_version' => $version, 202 | ])->save(); 203 | 204 | $model->enableRewindEvents(); 205 | } 206 | 207 | /** 208 | * Determine the model's current version. 209 | * 210 | * If a current_version column exists, return it. 211 | * Otherwise, fallback to the highest version from the versions table (a best guess). 212 | */ 213 | protected function determineCurrentVersion($model): int 214 | { 215 | if ($this->modelHasCurrentVersionColumn($model)) { 216 | // Use the stored current_version, defaulting to 0 217 | return $model->current_version ?? 0; 218 | } 219 | 220 | // If there's no current_version column, fallback to the highest known version 221 | return $model->versions()->max('version') ?? 0; 222 | } 223 | 224 | /** 225 | * Ensure the model uses the Rewindable trait. 226 | * 227 | * @throws ModelNotRewindableException 228 | * @throws CurrentVersionColumnMissingException 229 | */ 230 | protected function assertRewindable($model): void 231 | { 232 | if (collect(class_uses_recursive($model::class))->doesntContain(Rewindable::class)) { 233 | throw new ModelNotRewindableException(sprintf('%s must use the Rewindable trait in order to access Rewind functionality.', $model::class)); 234 | } 235 | 236 | if (! $this->modelHasCurrentVersionColumn($model)) { 237 | throw new CurrentVersionColumnMissingException($model); 238 | } 239 | } 240 | 241 | protected function eagerLoadVersions(Model $model): void 242 | { 243 | $model->load('versions'); 244 | } 245 | 246 | /** 247 | * Check if the model's table has a 'current_version' column. 248 | */ 249 | protected function modelHasCurrentVersionColumn($model): bool 250 | { 251 | // First, check the cache to avoid unnecessary queries 252 | $cacheKey = sprintf('rewind:tables:%s:has_current_version', $model->getTable()); 253 | if (Cache::has($cacheKey)) { 254 | // We only store true values in the cache, so just return true if the key exists. 255 | return true; 256 | } 257 | 258 | $result = Schema::connection($model->getConnectionName()) 259 | ->hasColumn($model->getTable(), 'current_version'); 260 | 261 | // If true, cache the result for a month We don't expect this to change. 262 | if ($result) { 263 | Cache::put($cacheKey, true, now()->addMonth()); 264 | } 265 | 266 | return $result; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/Traits/Rewindable.php: -------------------------------------------------------------------------------- 1 | getKeyName(), 40 | 'created_at', 41 | 'updated_at', 42 | 'current_version', 43 | ], $this->excludedFromVersioning()); 44 | } 45 | 46 | /** 47 | * Boot the trait. Registers relevant event listeners. 48 | */ 49 | public static function bootRewindable(): void 50 | { 51 | static::saved(function ($model) { 52 | $model->dispatchRewindEvent(); 53 | }); 54 | 55 | static::deleting(function ($model) { 56 | // Only dispatch a rewind event if the model is not force deleting 57 | if ($model->hasSoftDeletes() && ! $model->isForceDeleting()) { 58 | 59 | // Ensure `deleted_at` shows up as dirty 60 | $model->forceFill([$model->getDeletedAtColumn() => $model->freshTimestampString()]); 61 | 62 | $model->dispatchRewindEvent(); 63 | } 64 | }); 65 | 66 | static::deleted(function ($model) { 67 | // If the model is force deleting or does not use soft deletes, delete all versions 68 | if (! $model->hasSoftDeletes() || $model->isForceDeleting()) { 69 | $model->versions()->delete(); 70 | } 71 | }); 72 | } 73 | 74 | public function hasSoftDeletes(): bool 75 | { 76 | return in_array(SoftDeletes::class, class_uses_recursive($this)); 77 | } 78 | 79 | protected function dispatchRewindEvent(): void 80 | { 81 | // If the model signals it does not want Rewindable events, skip 82 | if (! empty($this->disableRewindEvents)) { 83 | return; 84 | } 85 | 86 | // If there's no change, don't fire the event 87 | if (empty($this->getDirty()) && ! $this->wasRecentlyCreated && $this->exists) { 88 | return; 89 | } 90 | 91 | event(new RewindVersionCreating($this)); 92 | } 93 | 94 | /** 95 | * Create a v1 snapshot of the model's current state if no versions exist. 96 | * 97 | * @throws LockTimeoutException 98 | */ 99 | public function initVersion(): void 100 | { 101 | cache()->lock( 102 | sprintf('laravel-rewind-version-lock-%s-%s', $this->getTable(), $this->getKey()), 103 | 10 104 | )->block(5, function () { 105 | 106 | // If versions already exist, skip 107 | if ($this->versions()->exists()) { 108 | return; 109 | } 110 | 111 | $this->versions()->create([ 112 | 'model_id' => $this->getKey(), 113 | 'model_type' => $this->getMorphClass(), 114 | 'old_values' => [], 115 | 'new_values' => $this->getAttributes(), 116 | 'version' => 1, 117 | 'is_snapshot' => true, 118 | ]); 119 | }); 120 | } 121 | 122 | /** 123 | * A hasMany relationship to the version records. 124 | */ 125 | public function versions(): MorphMany 126 | { 127 | return $this->morphMany(RewindVersion::class, 'model'); 128 | } 129 | 130 | /** 131 | * Get the user ID if tracking is enabled, otherwise null. 132 | * 133 | * @return int|string|null 134 | */ 135 | public function getRewindTrackUser() 136 | { 137 | if (! config('rewind.track_user')) { 138 | return null; 139 | } 140 | 141 | return optional(Auth::user())->getKey(); 142 | } 143 | 144 | public function disableRewindEvents(): void 145 | { 146 | $this->disableRewindEvents = true; 147 | } 148 | 149 | public function enableRewindEvents(): void 150 | { 151 | $this->disableRewindEvents = false; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /stubs/add_current_version_column.php.stub: -------------------------------------------------------------------------------- 1 | unsignedBigInteger('current_version')->nullable(); 16 | }); 17 | } 18 | 19 | /** 20 | * Reverse the migrations. 21 | */ 22 | public function down(): void 23 | { 24 | Schema::table('DummyTable', function (Blueprint $table) { 25 | $table->dropColumn('current_version'); 26 | }); 27 | } 28 | }; 29 | --------------------------------------------------------------------------------