├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── SECURITY.md └── stale.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── config └── versions.php ├── src ├── Concerns │ └── HasVersions.php ├── Contracts │ ├── Draftable.php │ └── Versionable.php ├── Facades │ └── LaravelVersions.php ├── Handlers │ └── DraftService.php ├── LaravelVersions.php ├── Macros │ └── VersionsBlueprintMacros.php ├── Observers │ └── VersionObserver.php └── VersionsServiceProvider.php └── tests ├── DefaultTest.php └── Unit ├── DraftTest.php ├── ExampleTest.php └── PublishTest.php /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: vildanbina 2 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you discover any security related issues, please email vildanbina@gmail.com instead of using the issue tracker. 4 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /.idea 3 | /.vscode 4 | composer.lock 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Vildan Bina 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 | [![Latest Stable Version](https://poser.pugx.org/vildanbina/laravel-versions/v)](https://packagist.org/packages/vildanbina/laravel-versions) 2 | [![Total Downloads](https://poser.pugx.org/vildanbina/laravel-versions/downloads)](https://packagist.org/packages/vildanbina/laravel-versions) 3 | [![Latest Unstable Version](https://poser.pugx.org/vildanbina/laravel-versions/v/unstable)](https://packagist.org/packages/vildanbina/laravel-versions) 4 | [![License](https://poser.pugx.org/vildanbina/laravel-versions/license)](https://packagist.org/packages/vildanbina/laravel-versions) 5 | [![PHP Version Require](https://poser.pugx.org/vildanbina/laravel-versions/require/php)](https://packagist.org/packages/vildanbina/laravel-versions) 6 | 7 | # Laravel Versions 8 | 9 | **Laravel Versions** is a package that adds powerful draft and versioning capabilities to your Eloquent models. With 10 | this package, you can create drafts, manage versions, and publish changes to your models without affecting the currently 11 | published version. When a model is updated, it modifies the existing active draft instead of creating a new one for each 12 | change. If no active draft exists, a new one is created. Once you're ready, you can publish the draft to make it the 13 | active version while maintaining a history of all previous versions. 14 | 15 | ## Requirements 16 | 17 | - PHP >= 8.0 18 | - Laravel 9.x, 10.x, or 11.x 19 | 20 | ## Installation 21 | 22 | You can install the package via Composer: 23 | 24 | ~~~bash 25 | composer require vildanbina/laravel-versions 26 | ~~~ 27 | 28 | After installation, you need to publish the configuration file: 29 | 30 | ~~~bash 31 | php artisan vendor:publish --provider="VildanBina\LaravelVersions\VersionsServiceProvider" 32 | ~~~ 33 | 34 | ### Database Migrations 35 | 36 | The package provides schema macros to add the necessary columns to your tables. You'll need to update your existing 37 | migrations or create new ones to add the drafts columns to your models' tables. 38 | 39 | To add the drafts columns to a table (e.g., `posts`), you can use the `drafts()` macro in your migration: 40 | 41 | ~~~php 42 | versionables(); 54 | }); 55 | } 56 | 57 | public function down() 58 | { 59 | Schema::table('posts', function (Blueprint $table) { 60 | $table->dropVersionables(); 61 | }); 62 | } 63 | } 64 | ~~~ 65 | 66 | After updating your migrations, run: 67 | 68 | ~~~bash 69 | php artisan migrate 70 | ~~~ 71 | 72 | ## Configuration 73 | 74 | The package includes a configuration file `config/drafts.php` that allows you to customize column names and the 75 | authentication guard. Below is the default configuration: 76 | 77 | ~~~php 78 | [ 82 | 'is_current' => 'is_current', 83 | 'is_published' => 'is_published', 84 | 'published_at' => 'published_at', 85 | 'uuid' => 'uuid', 86 | 'publisher_morph_name' => 'publisher', 87 | ], 88 | 89 | 'auth' => [ 90 | 'guard' => 'web', 91 | ], 92 | ]; 93 | ~~~ 94 | 95 | You can customize these settings as needed. 96 | 97 | ## Getting Started 98 | 99 | Follow these steps to set up versioning for your models: 100 | 101 | 1. **Install the package via Composer**: 102 | 103 | ~~~bash 104 | composer require vildanbina/laravel-versions 105 | ~~~ 106 | 107 | 2. **Publish the configuration file**: 108 | 109 | ~~~bash 110 | php artisan vendor:publish --provider="VildanBina\LaravelVersions\VersionsServiceProvider" 111 | ~~~ 112 | 113 | 3. **Add the drafts columns to your database**: 114 | 115 | Create a new migration or update an existing one to include the `drafts()` macro: 116 | 117 | ~~~php 118 | versionables(); 130 | }); 131 | } 132 | 133 | public function down() 134 | { 135 | Schema::table('posts', function (Blueprint $table) { 136 | $table->dropVersionables(); 137 | }); 138 | } 139 | } 140 | ~~~ 141 | 142 | Then run: 143 | 144 | ~~~bash 145 | php artisan migrate 146 | ~~~ 147 | 148 | 4. **Update your model**: 149 | 150 | Implement the `Versionable` interface and use the `HasVersions` trait: 151 | 152 | ~~~php 153 | 'My First Post', 'content' => 'Hello World']); 179 | 180 | // Publish the postha 181 | 182 | $post->publish(); 183 | 184 | // Update the post, which modifies the existing draft or creates a new one 185 | $post->update(['content' => 'Updated content']); 186 | 187 | // Get the current draft 188 | $draft = $post->draft; 189 | 190 | // Publish the draft 191 | $draft->publish(); 192 | ~~~ 193 | 194 | ## Usage 195 | 196 | To enable versioning for a model, implement the `Versionable` interface and use the `HasVersions` trait provided by the 197 | package. 198 | 199 | ### Example with a `Post` Model 200 | 201 | First, update your `Post` model: 202 | 203 | ~~~php 204 | 'Initial Title', 'content' => 'Initial Content']); 291 | 292 | // Publish the post 293 | $post->publish(); 294 | ~~~ 295 | 296 | #### Updating a Model and Working with Drafts 297 | 298 | ~~~php 299 | update(['title' => 'Updated Title']); 303 | 304 | // Get the current draft 305 | $draft = $post->draft; 306 | 307 | // Check if the post is a draft 308 | if ($draft->isDraft()) { 309 | echo "This post is currently a draft!"; 310 | } 311 | 312 | // Publish the updated draft 313 | $draft->publish(); 314 | ~~~ 315 | 316 | #### Retrieving Version History 317 | 318 | ~~~php 319 | revisions; 323 | 324 | // Loop through revisions 325 | foreach ($revisions as $revision) { 326 | echo $revision->title . ' - ' . ($revision->is_published ? 'Published' : 'Draft'); 327 | } 328 | ~~~ 329 | 330 | #### Getting the Publisher 331 | 332 | ~~~php 333 | publisher; 337 | ~~~ 338 | 339 | ## Query Scopes 340 | 341 | The package provides the following query scopes that can be used for querying models: 342 | 343 | - **`whereCurrent()`**: Retrieve records where `is_current` is `true`. 344 | - **`wherePublished()`**: Retrieve records where `is_published` is `true`. 345 | - **`withoutCurrent()`**: Retrieve records where `is_current` is `false`. 346 | - **`excludeRevision($revision)`**: Exclude a specific revision from the query. 347 | 348 | ### Examples 349 | 350 | ~~~php 351 | get(); 355 | 356 | // Retrieve all published posts 357 | $publishedPosts = Post::wherePublished()->get(); 358 | 359 | // Retrieve all drafts 360 | $drafts = Post::whereCurrent()->wherePublished(false)->get(); 361 | ~~~ 362 | 363 | ## Tips & Best Practices 364 | 365 | - **Using Transactions**: When performing operations that involve multiple steps, it's recommended to use database 366 | transactions to ensure data consistency. If any step fails, all changes will be rolled back, preventing any incomplete 367 | versioning states. 368 | 369 | ~~~php 370 | update(['title' => 'Updated Title']); 376 | $post->publish(); 377 | }); 378 | ~~~ 379 | 380 | - **Customizing Excluded Columns**: Use the `$excludedColumns` property in your model to specify columns that should not 381 | be versioned, such as timestamps or other metadata. 382 | 383 | - **Handling Unique Constraints**: Currently, the package doesn't manage unique constraints automatically. To ensure uniqueness (e.g., for slugs) only for published records, you can use a composite unique index combining `slug` and `is_published`. 384 | 385 | **Example Migration:** 386 | 387 | ```php 388 | return new class extends Migration { 389 | public function up() { 390 | Schema::create('posts', function (Blueprint $table) { 391 | // other columns 392 | $table->unique(['slug', 'is_published'], 'posts_slug_is_published_unique'); 393 | }); 394 | } 395 | }; 396 | ``` 397 | This setup ensures that only published posts have unique slugs, while drafts can share the same slug without conflicts. 398 | 399 | This concise addition provides clear guidance on handling unique constraints and includes a practical example for developers to implement. 400 | 401 | ## Extensibility and Customization 402 | 403 | The package is designed to be flexible and can be customized to fit your application's needs. 404 | 405 | ### Overriding Methods 406 | 407 | You can extend the functionality by overriding methods in the `HasVersions` trait within your model. 408 | 409 | ### Custom Events 410 | 411 | Leverage the `publishing` and `published` model events to add custom logic when a draft is being published. 412 | 413 | ### Customizing Authentication Guard 414 | 415 | You can change the authentication guard used to associate the publisher by updating the `auth.guard` setting in the 416 | `config/drafts.php` configuration file. 417 | 418 | ## Observers 419 | 420 | The package automatically handles the `creating`, `saving`, and `published` events via the `VersionObserver` to manage 421 | the 422 | draft lifecycle. If you need to customize this behavior, you can create your own observer or extend the existing one. 423 | 424 | ## To Do 425 | 426 | - **Handling Relationships**: Implementing support for all relationships in the versioning process, allowing 427 | relationships to be included and managed across drafts and published versions. 428 | 429 | - **Service for Detecting Changes**: Create a service that can identify and compare all changes between different 430 | versions of a model, providing a clear history of what has changed across versions. 431 | 432 | - **Enable/Disable Versioning**: Add functionality to globally enable or disable the versioning system, for scenarios 433 | such as when super admins want to bypass the versioning process or temporarily deactivate it. 434 | 435 | ## Contributing 436 | 437 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 438 | 439 | ## Security Vulnerabilities 440 | 441 | Please e-mail vildanbina@gmail.com to report any security vulnerabilities instead of the issue tracker. 442 | 443 | ## Credits 444 | 445 | - [Vildan Bina](https://github.com/vildanbina) 446 | - [All Contributors](../../contributors) 447 | 448 | ## License 449 | 450 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 451 | 452 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vildanbina/laravel-versions", 3 | "description": "A Laravel package for managing model drafts.", 4 | "keywords": [ 5 | "laravel", 6 | "versioning", 7 | "drafts", 8 | "models" 9 | ], 10 | "license": "MIT", 11 | "version": "1.0.0", 12 | "authors": [ 13 | { 14 | "name": "Vildan Bina", 15 | "email": "vildanbina@gmail.com", 16 | "homepage": "https://www.vildanbina.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=8.0", 22 | "laravel/framework": "^9.0|^10.0|^11.0", 23 | "illuminate/support": "^9.0|^10.0|^11.0" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^9.5" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "VildanBina\\LaravelVersions\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "VildanBina\\LaravelVersions\\Tests\\": "tests/" 36 | } 37 | }, 38 | "extra": { 39 | "laravel": { 40 | "providers": [ 41 | "VildanBina\\LaravelVersions\\VersionsServiceProvider" 42 | ], 43 | "aliases": { 44 | "LaravelVersions": "VildanBina\\LaravelVersions\\Facades\\LaravelVersions" 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /config/versions.php: -------------------------------------------------------------------------------- 1 | [ 5 | /* 6 | * Boolean column that marks a row as the current version of the data for editing. 7 | */ 8 | 'is_current' => 'is_current', 9 | 10 | /* 11 | * Boolean column that marks a row as live and displayable to the public. 12 | */ 13 | 'is_published' => 'is_published', 14 | 15 | /* 16 | * Timestamp column that stores the date and time when the row was published. 17 | */ 18 | 'published_at' => 'published_at', 19 | 20 | /* 21 | * UUID column that stores the unique identifier of the model drafts. 22 | */ 23 | 'uuid' => 'uuid', 24 | 25 | /* 26 | * Name of the morph relationship to the publishing user. 27 | */ 28 | 'publisher_morph_name' => 'publisher', 29 | ], 30 | 31 | 'auth' => [ 32 | /* 33 | * The guard to fetch the logged-in user from for the publisher relation. 34 | */ 35 | 'guard' => 'web', 36 | ], 37 | ]; 38 | -------------------------------------------------------------------------------- /src/Concerns/HasVersions.php: -------------------------------------------------------------------------------- 1 | addObservableEvents(['publishing', 'published']); 24 | 25 | $this->mergeFillable([ 26 | $this->getPublishedAtColumn(), 27 | $this->getIsCurrentColumn(), 28 | $this->getIsPublishedColumn(), 29 | $this->getUuidColumn(), 30 | ]); 31 | 32 | $this->mergeCasts([ 33 | $this->getPublishedAtColumn() => 'datetime', 34 | $this->getIsCurrentColumn() => 'boolean', 35 | $this->getIsPublishedColumn() => 'boolean', 36 | ]); 37 | 38 | $this->registerObserver(VersionObserver::class); 39 | } 40 | 41 | /** 42 | * Create a new draft version of the model. 43 | * 44 | * @return $this|null 45 | */ 46 | public function newDraft(): ?static 47 | { 48 | return $this->draftService()->createDraft(); 49 | } 50 | 51 | /** 52 | * Get the draft service instance. 53 | */ 54 | public function draftService(): Draftable 55 | { 56 | return new DraftService($this); 57 | } 58 | 59 | /** 60 | * Set the publisher of the model. 61 | * 62 | * @return $this 63 | */ 64 | public function setPublisher(): static 65 | { 66 | $publisherColumns = $this->getPublisherColumns(); 67 | 68 | if ($this->{$publisherColumns['id']} === null && LaravelVersions::getCurrentUser()) { 69 | $this->publisher()->associate(LaravelVersions::getCurrentUser()); 70 | } 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * Get the names of the publisher relation columns. 77 | * 78 | * @return array 79 | */ 80 | #[ArrayShape(['id' => 'string', 'type' => 'string'])] 81 | public function getPublisherColumns(): array 82 | { 83 | $morphName = config('versions.column_names.publisher_morph_name', 'publisher'); 84 | 85 | return [ 86 | 'id' => $morphName.'_id', 87 | 'type' => $morphName.'_type', 88 | ]; 89 | } 90 | 91 | /** 92 | * Define the publisher morph relation. 93 | * 94 | * @return MorphTo 95 | */ 96 | public function publisher() 97 | { 98 | return $this->morphTo(config('versions.column_names.publisher_morph_name', 'publisher')); 99 | } 100 | 101 | /** 102 | * Get the name of the "published at" column. 103 | */ 104 | public function getPublishedAtColumn(): string 105 | { 106 | return defined(static::class.'::PUBLISHED_AT') 107 | ? static::PUBLISHED_AT 108 | : config('versions.column_names.published_at', 'published_at'); 109 | } 110 | 111 | /** 112 | * Get the name of the "is current" column. 113 | */ 114 | public function getIsCurrentColumn(): string 115 | { 116 | return defined(static::class.'::IS_CURRENT') 117 | ? static::IS_CURRENT 118 | : config('versions.column_names.is_current', 'is_current'); 119 | } 120 | 121 | /** 122 | * Get the name of the "is published" column. 123 | */ 124 | public function getIsPublishedColumn(): string 125 | { 126 | return defined(static::class.'::IS_PUBLISHED') 127 | ? static::IS_PUBLISHED 128 | : config('versions.column_names.is_published', 'is_published'); 129 | } 130 | 131 | /** 132 | * Get the name of the "UUID" column. 133 | */ 134 | public function getUuidColumn(): string 135 | { 136 | return defined(static::class.'::UUID') 137 | ? static::UUID 138 | : config('versions.column_names.uuid', 'uuid'); 139 | } 140 | 141 | /** 142 | * Publish the model. 143 | * 144 | * @return $this 145 | */ 146 | public function publish(): static 147 | { 148 | if ($this->fireModelEvent('publishing') === false) { 149 | return $this; 150 | } 151 | 152 | $published = $this->draftService()->publish(); 153 | 154 | $published->fireModelEvent('published'); 155 | 156 | return $published; 157 | } 158 | 159 | /** 160 | * Get the columns to exclude during operations. 161 | */ 162 | public function getExcludedColumns(): array 163 | { 164 | return array_merge( 165 | array_values($this->getPublisherColumns()), 166 | [$this->getPublishedAtColumn()], 167 | property_exists($this, 'excludedColumns') ? $this->excludedColumns : [] 168 | ); 169 | } 170 | 171 | /** 172 | * Get the fully qualified publisher relation columns. 173 | */ 174 | public function getQualifiedPublisherColumns(): array 175 | { 176 | $columns = $this->getPublisherColumns(); 177 | 178 | return [ 179 | 'id' => $this->qualifyColumn($columns['id']), 180 | 'type' => $this->qualifyColumn($columns['type']), 181 | ]; 182 | } 183 | 184 | /** 185 | * Scope a query to only include current versions. 186 | */ 187 | public function scopeWhereCurrent(Builder $query): void 188 | { 189 | $query->where($this->getIsCurrentColumn(), true); 190 | } 191 | 192 | /** 193 | * Scope a query to only include published versions. 194 | */ 195 | public function scopeWherePublished(Builder $query, bool $value = true): void 196 | { 197 | $query->where($this->getIsPublishedColumn(), $value); 198 | } 199 | 200 | /** 201 | * Scope a query to exclude current versions. 202 | */ 203 | public function scopeWithoutCurrent(Builder $query): void 204 | { 205 | $query->where($this->getIsCurrentColumn(), false); 206 | } 207 | 208 | /** 209 | * Scope a query to exclude a specific revision. 210 | * 211 | * @return void 212 | */ 213 | public function scopeExcludeRevision(Builder $query, int|Model $exclude): Builder 214 | { 215 | $excludeId = $exclude instanceof Model ? $exclude->getKey() : $exclude; 216 | 217 | return $query->where($this->getKeyName(), '!=', $excludeId); 218 | } 219 | 220 | /** 221 | * Get all revisions of the model. 222 | * 223 | * @return HasMany 224 | */ 225 | public function revisions() 226 | { 227 | return $this->hasMany(static::class, $this->getUuidColumn(), $this->getUuidColumn()); 228 | } 229 | 230 | /** 231 | * Get the published version of the model. 232 | * 233 | * @return HasOne 234 | */ 235 | public function published() 236 | { 237 | return $this->hasOne(static::class, $this->getUuidColumn(), $this->getUuidColumn()) 238 | ->where($this->getIsPublishedColumn(), true); 239 | } 240 | 241 | /** 242 | * Get the draft version of the model. 243 | * 244 | * @return HasOne 245 | */ 246 | public function draft() 247 | { 248 | return $this->hasOne(static::class, $this->getUuidColumn(), $this->getUuidColumn()) 249 | ->where($this->getIsCurrentColumn(), true) 250 | ->where($this->getIsPublishedColumn(), false); 251 | } 252 | 253 | /** 254 | * Get the draft version excluding the current instance. 255 | * 256 | * @return $this|null 257 | */ 258 | public function draftWithoutSelf(): ?static 259 | { 260 | return $this->draft()->whereNot($this->getKeyName(), $this->getKey())->first(); 261 | } 262 | 263 | /** 264 | * Determine if the model is a draft. 265 | */ 266 | public function isDraft(): bool 267 | { 268 | $draft = $this->draft; 269 | 270 | return $draft !== null && $draft->getKey() !== $this->getKey(); 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/Contracts/Draftable.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function getPublisherColumns(): array; 13 | 14 | /** 15 | * Get the name of the UUID column. 16 | */ 17 | public function getUuidColumn(): string; 18 | 19 | /** 20 | * Get the name of the "is current" column. 21 | */ 22 | public function getIsCurrentColumn(): string; 23 | 24 | /** 25 | * Get the name of the "is published" column. 26 | */ 27 | public function getIsPublishedColumn(): string; 28 | 29 | /** 30 | * Set the publisher of the model. 31 | */ 32 | public function setPublisher(): static; 33 | 34 | /** 35 | * Get the columns to exclude during operations. 36 | * 37 | * @return array 38 | */ 39 | public function getExcludedColumns(): array; 40 | 41 | /** 42 | * Get the name of the "published at" column. 43 | */ 44 | public function getPublishedAtColumn(): string; 45 | 46 | /** 47 | * Get the draft version excluding the current instance. 48 | */ 49 | public function draftWithoutSelf(): ?static; 50 | 51 | /** 52 | * Determine if the model is a draft. 53 | */ 54 | public function isDraft(): bool; 55 | } 56 | -------------------------------------------------------------------------------- /src/Facades/LaravelVersions.php: -------------------------------------------------------------------------------- 1 | model->isDirty()) { 21 | return null; 22 | } 23 | 24 | $excludedColumns = $this->model->getExcludedColumns(); 25 | $uuidColumn = $this->model->getUuidColumn(); 26 | 27 | $draft = $this->model->newInstance(array_merge( 28 | Arr::except($this->model->toArray(), $excludedColumns), 29 | [ 30 | $uuidColumn => $this->model->{$uuidColumn}, 31 | $this->model->getIsCurrentColumn() => true, 32 | $this->model->getIsPublishedColumn() => false, 33 | ] 34 | )); 35 | 36 | $draft->saveQuietly(); 37 | 38 | return $draft; 39 | } 40 | 41 | /** 42 | * Publish the draft model. 43 | */ 44 | public function publish(): Versionable 45 | { 46 | $published = $this->model->published ?? $this->model; // In case of creating 47 | /* @var Versionable $draft */ 48 | $draft = $published->draftWithoutSelf(); 49 | 50 | $draftData = $draft?->toArray() ?? []; 51 | $publishedData = $published->toArray(); 52 | 53 | if ($draft) { 54 | $draft->fill(array_merge( 55 | $publishedData, 56 | [ 57 | $this->model->getIsCurrentColumn() => false, 58 | $this->model->getIsPublishedColumn() => false, 59 | ], 60 | )); 61 | $draft->setPublisher(); 62 | $draft->saveQuietly(); 63 | } 64 | 65 | $published->fill(array_merge( 66 | $draftData, 67 | [ 68 | $this->model->getIsCurrentColumn() => true, 69 | $this->model->getIsPublishedColumn() => true, 70 | $this->model->getPublishedAtColumn() => now(), 71 | ], 72 | )); 73 | 74 | $published->setPublisher(); 75 | $published->saveQuietly(); 76 | 77 | $this->model->setRelation('published', $published->withoutRelations()); 78 | 79 | return $published; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/LaravelVersions.php: -------------------------------------------------------------------------------- 1 | user(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Macros/VersionsBlueprintMacros.php: -------------------------------------------------------------------------------- 1 | uuid($uuid)->nullable(); 26 | $this->timestamp($publishedAt)->nullable(); 27 | $this->boolean($isPublished)->default(false); 28 | $this->boolean($isCurrent)->default(false); 29 | $this->nullableMorphs($publisherMorphName); 30 | 31 | $this->index([$uuid, $isPublished, $isCurrent]); 32 | }); 33 | 34 | Blueprint::macro('dropVersionables', function ( 35 | ?string $uuid = null, 36 | ?string $publishedAt = null, 37 | ?string $isPublished = null, 38 | ?string $isCurrent = null, 39 | ?string $publisherMorphName = null, 40 | ): void { 41 | /** @var Blueprint $this */ 42 | $uuid ??= config('versions.column_names.uuid', 'uuid'); 43 | $publishedAt ??= config('versions.column_names.published_at', 'published_at'); 44 | $isPublished ??= config('versions.column_names.is_published', 'is_published'); 45 | $isCurrent ??= config('versions.column_names.is_current', 'is_current'); 46 | $publisherMorphName ??= config('versions.column_names.publisher_morph_name', 'publisher_morph_name'); 47 | 48 | $this->dropIndex([$uuid, $isPublished, $isCurrent]); 49 | $this->dropMorphs($publisherMorphName); 50 | 51 | $this->dropColumn([ 52 | $uuid, 53 | $publishedAt, 54 | $isPublished, 55 | $isCurrent, 56 | ]); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Observers/VersionObserver.php: -------------------------------------------------------------------------------- 1 | getUuidColumn(); 16 | $model->{$model->getIsCurrentColumn()} = true; 17 | $model->{$model->getIsPublishedColumn()} = false; 18 | 19 | if (! $model->{$uuidColumn}) { 20 | $model->{$uuidColumn} = (string) Str::uuid(); 21 | } 22 | } 23 | 24 | /** 25 | * Handle the "updating" event. 26 | * 27 | * @return bool 28 | */ 29 | public function updating(Versionable $model) 30 | { 31 | if ($model->isDirty() && $model->{$model->getIsPublishedColumn()}) { 32 | $model->fresh()->updateQuietly([$model->getIsCurrentColumn() => false]); 33 | 34 | $draft = $model->newDraft(); 35 | $model->setRelation('draft', $draft); 36 | 37 | return false; 38 | } 39 | 40 | return true; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/VersionsServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/versions.php', 'drafts'); 16 | } 17 | 18 | public function register(): void 19 | { 20 | $this->app->singleton(LaravelVersions::class, fn () => new LaravelVersions); 21 | VersionsBlueprintMacros::register(); 22 | } 23 | 24 | /** 25 | * @return string[] 26 | */ 27 | public function provides(): array 28 | { 29 | return [ 30 | 'laravel-versions', 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/DefaultTest.php: -------------------------------------------------------------------------------- 1 | delete(); 24 | 25 | $this->user = User::factory()->create(); 26 | $this->actingAs($this->user, 'web'); 27 | } 28 | 29 | protected function createPost(array $additionalData = []): Post 30 | { 31 | return Post::factory()->create([ 32 | 'user_id' => $this->user->id, 33 | 'description' => 'Test', 34 | ...$additionalData, 35 | ]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Unit/DraftTest.php: -------------------------------------------------------------------------------- 1 | createPost(); 12 | $post->publish(); 13 | 14 | $post->title = 'Unknown Title'; 15 | $draft = tap($post, fn ($updatedPost) => $updatedPost->save())->draftWithoutSelf(); 16 | 17 | $this->assertNotNull($draft); 18 | $this->assertTrue($draft->is_current); 19 | $this->assertFalse($draft->is_published); 20 | $this->assertEquals($post->uuid, $draft->uuid); 21 | } 22 | 23 | public function test_draft_inherits_attributes_from_original() 24 | { 25 | $post = $this->createPost(); 26 | $post->publish(); 27 | 28 | $post->title = 'Original Title'; 29 | $draft = tap($post, fn ($updatedPost) => $updatedPost->save())->draftWithoutSelf(); 30 | 31 | $this->assertEquals('Original Title', $draft->title); 32 | } 33 | 34 | public function test_draft_has_separate_id_from_original() 35 | { 36 | $post = $this->createPost(); 37 | $post->publish(); 38 | 39 | $post->title = 'Unknown Title'; 40 | $draft = tap($post, fn ($updatedPost) => $updatedPost->save())->draftWithoutSelf(); 41 | 42 | $this->assertNotEquals($post->id, $draft->id); 43 | $this->assertEquals($post->uuid, $draft->uuid); 44 | } 45 | 46 | public function test_drafting_unpublished_post_returns_null_or_handles_appropriately() 47 | { 48 | $post = $this->createPost(); // Not published 49 | 50 | $draft = $post->newDraft(); 51 | 52 | // Assuming it returns null when trying to create a draft of an unpublished post 53 | $this->assertNull($draft); 54 | } 55 | 56 | public function test_drafting_published_post_creates_new_draft() 57 | { 58 | $post = $this->createPost(); 59 | $post->publish(); 60 | 61 | $post->title = 'Unknown Title'; 62 | $draft = tap($post, fn ($updatedPost) => $updatedPost->save())->draftWithoutSelf(); 63 | 64 | $this->assertNotNull($draft); 65 | $this->assertFalse($draft->is_published); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/Unit/ExampleTest.php: -------------------------------------------------------------------------------- 1 | createPost(); 16 | 17 | $this->assertTrue( 18 | $post->is_current && 19 | ! $post->is_published 20 | ); 21 | } 22 | 23 | public function test_publish_post(): void 24 | { 25 | $post = $this->createPost(); 26 | $post->publish(); 27 | 28 | $this->assertDatabaseHas('posts', [ 29 | 'id' => $post->id, 30 | 'is_current' => true, 31 | 'is_published' => true, 32 | 'uuid' => $post->uuid, 33 | ]); 34 | 35 | // check if there's no other drafts 36 | // since the first post created is published 37 | $this->assertDatabaseMissing('posts', [ 38 | ['id', '!=', $post->id], 39 | 'uuid' => $post->uuid, 40 | ]); 41 | } 42 | 43 | public function test_make_changes_to_published_post(): void 44 | { 45 | $post = $this->createPost(); 46 | $publishedPost = $post->publish(); 47 | 48 | $post = $post->fresh(); 49 | $post->title = $this->faker->text; 50 | $post->description = 'Approved'; 51 | $post->save(); 52 | $draftPost = $post->draftWithoutSelf(); 53 | 54 | $this->assertDatabaseHas('posts', [ 55 | 'id' => $publishedPost->id, 56 | 'is_current' => false, 57 | 'is_published' => true, 58 | 'title' => $publishedPost->title, 59 | 'description' => $publishedPost->description, 60 | 'uuid' => $publishedPost->uuid, 61 | ]); 62 | 63 | $this->assertDatabaseHas('posts', [ 64 | 'id' => $draftPost->id, 65 | 'is_current' => true, 66 | 'is_published' => false, 67 | 'title' => $draftPost->title, 68 | 'uuid' => $publishedPost->uuid, 69 | ]); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Unit/PublishTest.php: -------------------------------------------------------------------------------- 1 | createPost(); 12 | $post->publish(); 13 | 14 | $post->title = 'Draft Title'; 15 | $post->save(); 16 | 17 | $post->publish(); 18 | 19 | $this->assertEquals('Draft Title', $post->title); 20 | $this->assertTrue($post->is_published); 21 | } 22 | 23 | public function test_publish_sets_published_at_and_publisher() 24 | { 25 | $post = $this->createPost(); 26 | $post->publish(); 27 | 28 | $this->assertNotNull($post->published_at); 29 | $this->assertNotNull($post->publisher); 30 | } 31 | 32 | public function test_publishing_without_current_draft_does_nothing() 33 | { 34 | $post = $this->createPost(); 35 | $post->publish(); 36 | 37 | $publishedPost = $post->publish(); 38 | 39 | $this->assertEquals($post->id, $publishedPost->id); 40 | $this->assertTrue($post->is_published); 41 | } 42 | 43 | public function test_publishing_draft_of_unpublished_post_publishes_post() 44 | { 45 | $post = $this->createPost(); // Not published 46 | 47 | $post->title = 'New Title'; 48 | $post->save(); 49 | 50 | // Assert that a draft is created 51 | $this->assertNotNull($post); 52 | $this->assertFalse($post->is_published); 53 | $this->assertTrue($post->is_current); 54 | 55 | // Publish the draft 56 | $post->publish(); 57 | 58 | // Verify that the original post is now published with updated attributes 59 | $this->assertTrue($post->is_published); 60 | $this->assertEquals('New Title', $post->title); 61 | $this->assertTrue($post->is_current); 62 | } 63 | 64 | public function test_drafting_unpublished_post_saves_changes_directly() 65 | { 66 | $post = $this->createPost(['title' => 'New Title']); // Not published 67 | 68 | // Assert that no draft was created 69 | $draft = $post->draftWithoutSelf(); 70 | $this->assertNull($draft); 71 | 72 | // Verify that the post's attributes were updated directly 73 | $this->assertEquals('New Title', $post->title); 74 | $this->assertFalse($post->is_published); 75 | $this->assertTrue($post->is_current); 76 | } 77 | } 78 | --------------------------------------------------------------------------------