├── postcss.config.mjs ├── database └── .gitignore ├── CHANGELOG.md ├── config ├── docgen.php ├── revisor.php └── ide-helper.php ├── package.json ├── src ├── Enums │ └── RevisorContext.php ├── Middleware │ ├── DraftMiddleware.php │ ├── PublishedMiddleware.php │ └── DraftableMiddleware.php ├── RevisorServiceProvider.php ├── Facades │ └── Revisor.php ├── Contracts │ └── HasRevisor.php ├── Concerns │ ├── HasRevisor.php │ ├── HasPublishing.php │ └── HasVersioning.php └── Revisor.php ├── LICENSE.md ├── README.md └── composer.json /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: {}, 3 | }; 4 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-revisor` will be documented in this file. 4 | -------------------------------------------------------------------------------- /config/docgen.php: -------------------------------------------------------------------------------- 1 | Indra\Revisor\Facades\Revisor::class, 5 | 'classes' => [Indra\Revisor\Revisor::class], 6 | ]; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "docs:dev": "vitepress dev docs", 4 | "docs:build": "vitepress build docs" 5 | }, 6 | "devDependencies": { 7 | "vitepress": "^1.4.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Enums/RevisorContext.php: -------------------------------------------------------------------------------- 1 | isDraftRequest($request)) { 16 | Revisor::draftContext(); 17 | } 18 | 19 | return $next($request); 20 | } 21 | 22 | private function isDraftRequest(Request $request): bool 23 | { 24 | return $request->has('draft'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/RevisorServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-revisor')->hasConfigFile(); 21 | } 22 | 23 | public function packageBooted(): void 24 | { 25 | // ensure the middlewares are registered before the SubstituteBindings middleware 26 | $this->app[Kernel::class]->prependToMiddlewarePriority(DraftMiddleware::class); 27 | $this->app[Kernel::class]->prependToMiddlewarePriority(PublishedMiddleware::class); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Indra 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 Revisor 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/indracollective/laravel-revisor.svg?style=flat-square)](https://packagist.org/packages/indracollective/laravel-revisor) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/indracollective/laravel-revisor/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/indracollective/laravel-revisor/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/indracollective/laravel-revisor/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/indracollective/laravel-revisor/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/indracollective/laravel-revisor.svg?style=flat-square)](https://packagist.org/packages/indracollective/laravel-revisor) 7 | 8 | **Robust draft, publishing and versioning for Laravel Eloquent Models.** 9 | 10 | ## Installation 11 | 12 | ```bash 13 | composer require indracollective/laravel-revisor 14 | ``` 15 | 16 | For FilamentPHP support install [laravel-revisor-filament](https://github.com/indracollective/laravel-revisor-filament) 17 | 18 | ```bash 19 | composer require indracollective/laravel-revisor-filament 20 | ``` 21 | 22 | ## Documentation 23 | 24 | [laravel-revisor.indracollective.dev](https://laravel-revisor.indracollective.dev) 25 | -------------------------------------------------------------------------------- /src/Facades/Revisor.php: -------------------------------------------------------------------------------- 1 | [ 11 | RevisorContext::Draft->value => '_drafts', 12 | RevisorContext::Version->value => '_versions', 13 | RevisorContext::Published->value => '_published', 14 | ], 15 | 16 | // The default mode determines which of your Revisor enabled Model's tables will read/written to by default 17 | // The RevisorContext enum is used to define the possible values for this 18 | // The options are `Draft`, `Version` and `Published` 19 | 'default_context' => RevisorContext::Published, 20 | 21 | // Publishing configuration 22 | 'publishing' => [ 23 | // Determines whether records should be automatically published when created/updated 24 | // These can be overridden on a Model instance as needed, see \Indra\Revisor\Concerns\HasPublishing 25 | 'publish_on_created' => false, 26 | 'publish_on_updated' => false, 27 | 28 | // The names of table columns that store publishing data 29 | 'table_columns' => [ 30 | 'is_published' => 'is_published', 31 | 'published_at' => 'published_at', 32 | 'publisher' => 'publisher', 33 | ], 34 | ], 35 | 36 | // The publishing config is used to determine the default versioning behaviour, 37 | 'versioning' => [ 38 | // Determines whether records should have new versions created when created/updated 39 | // These can be overridden on a Model instance as needed, see \Indra\Revisor\Concerns\HasVersioning 40 | 'save_new_version_on_created' => true, 41 | 'save_new_version_on_updated' => true, 42 | 43 | // The maximum number of versions to keep 44 | // if set to true, version records will not be pruned 45 | 'keep_versions' => 10, 46 | 47 | // The names of table columns that store versioning data 48 | 'table_columns' => [ 49 | 'is_current' => 'is_current', 50 | 'version_number' => 'version_number', 51 | 'record_id' => 'record_id', 52 | ], 53 | ], 54 | ]; 55 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "indracollective/laravel-revisor", 3 | "description": "Draft, publish and revise Laravel Eloquent Models", 4 | "keywords": [ 5 | "Indra", 6 | "Collective", 7 | "laravel", 8 | "laravel-revisor", 9 | "draft", 10 | "publish", 11 | "revise", 12 | "versions", 13 | "eloquent", 14 | "model" 15 | ], 16 | "homepage": "https://github.com/indracollective/laravel-revisor", 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Shea Dawson", 21 | "email": "shea@livesource.co.nz", 22 | "role": "Developer" 23 | } 24 | ], 25 | "require": { 26 | "php": "^8.2", 27 | "spatie/laravel-package-tools": "^1.16", 28 | "illuminate/contracts": "^11.0||^12.0" 29 | }, 30 | "require-dev": { 31 | "laravel/pint": "^1.14", 32 | "nunomaduro/collision": "^8.1.1||^7.10.0", 33 | "larastan/larastan": "^2.9||^3.0", 34 | "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", 35 | "pestphp/pest": "^3.0", 36 | "pestphp/pest-plugin-arch": "^3.0", 37 | "pestphp/pest-plugin-laravel": "^3.0", 38 | "phpstan/extension-installer": "^1.3||^2.0", 39 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", 40 | "phpstan/phpstan-phpunit": "^1.3||^2.0", 41 | "spatie/laravel-ray": "^1.35", 42 | "irazasyed/docgen": "^0.2.0" 43 | }, 44 | "autoload": { 45 | "psr-4": { 46 | "Indra\\Revisor\\": "src" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "Indra\\Revisor\\Tests\\": "tests", 52 | "Workbench\\App\\": "workbench/app/" 53 | } 54 | }, 55 | "scripts": { 56 | "post-autoload-dump": [ 57 | "@clear", 58 | "@prepare", 59 | "@composer run prepare" 60 | ], 61 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 62 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 63 | "build": "@php vendor/bin/testbench workbench:build --ansi", 64 | "start": [ 65 | "Composer\\Config::disableProcessTimeout", 66 | "@composer run build", 67 | "@php vendor/bin/testbench serve" 68 | ], 69 | "analyse": "vendor/bin/phpstan analyse", 70 | "test": "vendor/bin/pest", 71 | "test-coverage": "vendor/bin/pest --coverage", 72 | "format": "vendor/bin/pint", 73 | "docgen": "vendor/bin/docgen -c ./config/docgen.php", 74 | "serve": [ 75 | "Composer\\Config::disableProcessTimeout", 76 | "@build", 77 | "@php vendor/bin/testbench serve --ansi" 78 | ], 79 | "lint": [ 80 | "@php vendor/bin/pint --ansi", 81 | "@php vendor/bin/phpstan analyse --verbose --ansi" 82 | ] 83 | }, 84 | "config": { 85 | "sort-packages": true, 86 | "allow-plugins": { 87 | "pestphp/pest-plugin": true, 88 | "phpstan/extension-installer": true 89 | } 90 | }, 91 | "extra": { 92 | "laravel": { 93 | "providers": [ 94 | "Indra\\Revisor\\RevisorServiceProvider" 95 | ], 96 | "aliases": { 97 | "Revisor": "Indra\\Revisor\\Facades\\Revisor" 98 | } 99 | } 100 | }, 101 | "minimum-stability": "dev", 102 | "prefer-stable": true 103 | } 104 | -------------------------------------------------------------------------------- /src/Contracts/HasRevisor.php: -------------------------------------------------------------------------------- 1 | isVersionTableRecord()) { 27 | $model->handleVersionDeletion(); 28 | } 29 | 30 | if ($model->isDraftTableRecord()) { 31 | $model->handleDraftDeletion(); 32 | } 33 | 34 | if ($model->isPublishedTableRecord()) { 35 | $model->handlePublishedDeletion(); 36 | } 37 | }); 38 | 39 | if (method_exists(static::class, 'forceDeleted')) { 40 | static::forceDeleted(function (HasRevisorContract $model) { 41 | if ($model->isVersionTableRecord()) { 42 | $model->handleVersionDeletion(); 43 | } 44 | 45 | if ($model->isDraftTableRecord()) { 46 | $model->handleDraftDeletion(force: true); 47 | } 48 | 49 | if ($model->isPublishedTableRecord()) { 50 | $model->handlePublishedDeletion(); 51 | } 52 | }); 53 | } 54 | 55 | if (method_exists(static::class, 'restored')) { 56 | static::restored(function (HasRevisorContract $model): void { 57 | if ($model->isDraftTableRecord()) { 58 | $model->publishedRecord?->restoreQuietly(); 59 | $model->versionRecords->restoreQuietly(); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | public function newInstance($attributes = [], $exists = false): self 66 | { 67 | return parent::newInstance($attributes, $exists) 68 | ->setRevisorContext($this->getRevisorContext() ?? Revisor::getContext()); 69 | } 70 | 71 | /** 72 | * Overrides Model::getTable to return the appropriate 73 | * table (draft, version, published) based on 74 | * the current RevisorContext 75 | */ 76 | public function getTable(): string 77 | { 78 | return Revisor::getSuffixedTableNameFor($this->getBaseTable(), $this->getRevisorContext()); 79 | } 80 | 81 | /** 82 | * Get the base table name for the model 83 | */ 84 | public function getBaseTable(): string 85 | { 86 | return $this->baseTable ?? Str::snake(Str::pluralStudly(class_basename($this))); 87 | } 88 | 89 | /** 90 | * Get the Draft table name for the model 91 | */ 92 | public function getDraftTable(): string 93 | { 94 | return Revisor::getDraftTableFor($this->getBaseTable()); 95 | } 96 | 97 | /** 98 | * Get the draft record for this model 99 | * 100 | * @throws Exception 101 | */ 102 | public function draftRecord(): HasOne 103 | { 104 | if ($this->isDraftTableRecord()) { 105 | throw new Exception('The draft record HasOne relationship is only available to Published and Version records'); 106 | } 107 | 108 | $instance = (new static)->withDraftContext(); 109 | $localKey = $this->isVersionTableRecord() ? 'record_id' : $this->getKeyName(); 110 | 111 | return $this->newHasOne( 112 | $instance, $this, $instance->getModel()->getTable().'.'.$this->getKeyName(), $localKey 113 | ); 114 | } 115 | 116 | /** 117 | * Get a Builder instance for the Draft table 118 | */ 119 | public function scopeWithDraftContext(Builder $query): Builder 120 | { 121 | $query->getModel()->setRevisorContext(RevisorContext::Draft); 122 | $query->getQuery()->from = $query->getModel()->getTable(); 123 | 124 | return $query; 125 | } 126 | 127 | /** 128 | * Check if the model is a Draft table record 129 | */ 130 | public function isDraftTableRecord(): bool 131 | { 132 | return $this->getTable() === $this->getDraftTable(); 133 | } 134 | 135 | /** 136 | * Handle the deletion of a version record 137 | * Remove version_number from published and draft records 138 | */ 139 | public function handleVersionDeletion(): void 140 | { 141 | if (! $this->isVersionTableRecord()) { 142 | return; 143 | } 144 | 145 | foreach ([$this->getPublishedTable(), $this->getDraftTable()] as $table) { 146 | DB::table($table) 147 | ->where('id', $this->record_id) 148 | ->where('version_number', $this->version_number) 149 | ->update(['version_number' => null]); 150 | } 151 | } 152 | 153 | public function setRevisorContext(?RevisorContext $context = null): static 154 | { 155 | $this->revisorContext = $context; 156 | 157 | return $this; 158 | } 159 | 160 | public function getRevisorContext(): ?RevisorContext 161 | { 162 | return $this->revisorContext; 163 | } 164 | 165 | /** 166 | * Handle the deletion of a draft record 167 | * Cascades the deletion to the version and draft records 168 | * Accounting for forceDeletes 169 | */ 170 | public function handleDraftDeletion(bool $force = false): void 171 | { 172 | if (! $this->isDraftTableRecord()) { 173 | return; 174 | } 175 | 176 | $force ? 177 | $this->publishedRecord?->forceDelete() : 178 | $this->publishedRecord?->delete(); 179 | 180 | $force ? 181 | $this->versionRecords->each->forceDeleteQuietly() : 182 | $this->versionRecords->each->deleteQuietly(); 183 | } 184 | 185 | /** 186 | * Handle the deletion of a published record 187 | * Ensures the draft and current versions 188 | * are marked as unpublished 189 | */ 190 | public function handlePublishedDeletion(): void 191 | { 192 | if (! $this->isPublishedTableRecord()) { 193 | return; 194 | } 195 | 196 | $this->draftRecord?->unpublish(); 197 | 198 | $this->currentVersionRecord?->unpublish(); 199 | } 200 | 201 | /** 202 | * Get the Revisor statuses for the model 203 | */ 204 | public function getRevisorStatuses(): array 205 | { 206 | if (! $this->isPublished()) { 207 | return ['draft']; 208 | } 209 | 210 | if (! $this->isRevised()) { 211 | return ['published']; 212 | } 213 | 214 | return ['published', 'revised']; 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/Revisor.php: -------------------------------------------------------------------------------- 1 | boolean(config('revisor.publishing.table_columns.is_published'))->default(0); 29 | $table->timestamp(config('revisor.publishing.table_columns.published_at'))->nullable(); 30 | $table->nullableMorphs(config('revisor.publishing.table_columns.publisher')); 31 | $table->boolean(config('revisor.versioning.table_columns.is_current'))->default(0); 32 | $table->unsignedInteger(config('revisor.versioning.table_columns.version_number'))->unsigned()->nullable()->index(); 33 | }); 34 | 35 | // create the versions table 36 | Schema::create(static::getVersionTableFor($baseTableName), function (Blueprint $table) use ($callback, $baseTableName, $model) { 37 | $callback($table, RevisorContext::Version); 38 | $table->boolean(config('revisor.publishing.table_columns.is_published'))->default(0)->index(); 39 | $table->timestamp(config('revisor.publishing.table_columns.published_at'))->nullable(); 40 | $table->nullableMorphs(config('revisor.publishing.table_columns.publisher')); 41 | $table->boolean(config('revisor.versioning.table_columns.is_current'))->default(0)->index(); 42 | $table->unsignedInteger(config('revisor.versioning.table_columns.version_number'))->unsigned()->nullable()->index(); 43 | 44 | if ($model) { 45 | // allows for uuid and ulid primary keys 46 | $table->foreignIdFor($model, config('revisor.versioning.table_columns.record_id'))->constrained(static::getDraftTableFor($baseTableName))->cascadeOnDelete(); 47 | } else { 48 | $table->foreignId(config('revisor.versioning.table_columns.record_id'))->constrained(static::getDraftTableFor($baseTableName))->cascadeOnDelete(); 49 | } 50 | }); 51 | 52 | // create the published table 53 | Schema::create(static::getPublishedTableFor($baseTableName), function (Blueprint $table) use ($callback) { 54 | $callback($table, RevisorContext::Published); 55 | $table->boolean(config('revisor.publishing.table_columns.is_published'))->default(0); 56 | $table->timestamp(config('revisor.publishing.table_columns.published_at'))->nullable(); 57 | $table->nullableMorphs(config('revisor.publishing.table_columns.publisher')); 58 | $table->boolean(config('revisor.versioning.table_columns.is_current'))->default(0); 59 | $table->integer(config('revisor.versioning.table_columns.version_number'))->unsigned()->nullable()->index(); 60 | }); 61 | } 62 | 63 | /** 64 | * Alters 3 tables for the given baseTableName: 65 | * - {baseTableName}_versions, which holds all the versions of the records 66 | * - {baseTableName}_live, which holds the published version of the records 67 | * - {baseTableName}, which holds the base data / drafts of the records 68 | */ 69 | public function alterTableSchemas(string $baseTableName, Closure $callback): void 70 | { 71 | // alter the versions table 72 | Schema::table(static::getVersionTableFor($baseTableName), function (Blueprint $table) use ($callback) { 73 | $callback($table, RevisorContext::Version); 74 | }); 75 | 76 | // alter the published table 77 | Schema::table(static::getPublishedTableFor($baseTableName), function (Blueprint $table) use ($callback) { 78 | $callback($table, RevisorContext::Published); 79 | }); 80 | 81 | // alter the draft table 82 | Schema::table(static::getDraftTableFor($baseTableName), function (Blueprint $table) use ($callback) { 83 | $callback($table, RevisorContext::Draft); 84 | }); 85 | } 86 | 87 | /** 88 | * Schema::dropIfExists() all the tables for the given baseTableName 89 | */ 90 | public function dropTableSchemasIfExists(string $baseTableName): void 91 | { 92 | $this->getAllTablesFor($baseTableName)->each(function ($tableName) { 93 | Schema::dropIfExists($tableName); 94 | }); 95 | } 96 | 97 | /** 98 | * Get the name of the table that holds the versions 99 | * of the records for the given baseTableName 100 | */ 101 | public function getVersionTableFor(string $baseTableName): string 102 | { 103 | return $this->getSuffixedTableNameFor($baseTableName, RevisorContext::Version); 104 | } 105 | 106 | /** 107 | * Get the name of the table that holds the published 108 | * records for the given baseTableName 109 | */ 110 | public function getPublishedTableFor(string $baseTableName): string 111 | { 112 | return $this->getSuffixedTableNameFor($baseTableName, RevisorContext::Published); 113 | } 114 | 115 | /** 116 | * Get the name of the table that holds the draft 117 | * records for the given baseTableName 118 | */ 119 | public function getDraftTableFor(string $baseTableName): string 120 | { 121 | return $this->getSuffixedTableNameFor($baseTableName, RevisorContext::Draft); 122 | } 123 | 124 | /** 125 | * Get the suffixed table name for the given baseTableName 126 | * and RevisorContext (defaults to the active RevisorContext) 127 | */ 128 | public function getSuffixedTableNameFor(string $baseTableName, ?RevisorContext $context = null): string 129 | { 130 | $context = $context ?? $this->getContext(); 131 | 132 | $suffix = config('revisor.table_suffixes.'.$context->value); 133 | 134 | return $suffix ? $baseTableName.$suffix : $baseTableName; 135 | } 136 | 137 | /** 138 | * Get all the tables for the given baseTableName 139 | */ 140 | public function getAllTablesFor(string $baseTableName): Collection 141 | { 142 | return collect([ 143 | // Version table should be returned first so it gets dropped first in dropTableSchemasIfExists 144 | $this->getVersionTableFor($baseTableName), 145 | $this->getDraftTableFor($baseTableName), 146 | $this->getPublishedTableFor($baseTableName), 147 | ]); 148 | } 149 | 150 | /** 151 | * Get the current RevisorContext 152 | */ 153 | public function getContext(bool $orDefaultContext = true): ?RevisorContext 154 | { 155 | $value = Context::get(RevisorContext::KEY); 156 | 157 | if ($value) { 158 | return RevisorContext::from($value); 159 | } 160 | 161 | return $orDefaultContext ? config('revisor.default_context') : null; 162 | } 163 | 164 | /** 165 | * Set the current RevisorContext 166 | */ 167 | public function setContext(RevisorContext $context): static 168 | { 169 | Context::add(RevisorContext::KEY, $context->value); 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Set the current RevisorContext to Draft 176 | */ 177 | public function draftContext(): static 178 | { 179 | $this->setContext(RevisorContext::Draft); 180 | 181 | return $this; 182 | } 183 | 184 | /** 185 | * Set the current RevisorContext to Published 186 | */ 187 | public function publishedContext(): static 188 | { 189 | $this->setContext(RevisorContext::Published); 190 | 191 | return $this; 192 | } 193 | 194 | /** 195 | * Set the current RevisorContext to Version 196 | */ 197 | public function versionContext(): static 198 | { 199 | $this->setContext(RevisorContext::Version); 200 | 201 | return $this; 202 | } 203 | 204 | /** 205 | * Execute the given callback with the given RevisorContext 206 | * Useful for switching context temporarily 207 | */ 208 | public function withContext(RevisorContext $context, callable $callback): mixed 209 | { 210 | $previousContext = $this->getContext(false); 211 | 212 | $this->setContext($context); 213 | 214 | $result = $callback($this); 215 | 216 | $previousContext ? 217 | $this->setContext($previousContext) : 218 | Context::forget(RevisorContext::KEY); 219 | 220 | return $result; 221 | } 222 | 223 | /** 224 | * Execute the given callback with the Version RevisorContext 225 | */ 226 | public function withPublishedContext(callable $callback): mixed 227 | { 228 | return $this->withContext(RevisorContext::Published, $callback); 229 | } 230 | 231 | /** 232 | * Execute the given callback with the Version RevisorContext 233 | */ 234 | public function withVersionContext(callable $callback): mixed 235 | { 236 | return $this->withContext(RevisorContext::Version, $callback); 237 | } 238 | 239 | /** 240 | * Execute the given callback with the Draft RevisorContext 241 | */ 242 | public function withDraftContext(callable $callback): mixed 243 | { 244 | return $this->withContext(RevisorContext::Draft, $callback); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /config/ide-helper.php: -------------------------------------------------------------------------------- 1 | '_ide_helper.php', 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Models filename 19 | |-------------------------------------------------------------------------- 20 | | 21 | | The default filename for the models helper file. 22 | | 23 | */ 24 | 25 | 'models_filename' => '_ide_helper_models.php', 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | PhpStorm meta filename 30 | |-------------------------------------------------------------------------- 31 | | 32 | | PhpStorm also supports the directory `.phpstorm.meta.php/` with arbitrary 33 | | files in it, should you need additional files for your project; e.g. 34 | | `.phpstorm.meta.php/laravel_ide_Helper.php'. 35 | | 36 | */ 37 | 'meta_filename' => '.phpstorm.meta.php', 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Fluent helpers 42 | |-------------------------------------------------------------------------- 43 | | 44 | | Set to true to generate commonly used Fluent methods. 45 | | 46 | */ 47 | 48 | 'include_fluent' => false, 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Factory builders 53 | |-------------------------------------------------------------------------- 54 | | 55 | | Set to true to generate factory generators for better factory() 56 | | method auto-completion. 57 | | 58 | | Deprecated for Laravel 8 or latest. 59 | | 60 | */ 61 | 62 | 'include_factory_builders' => false, 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | Write model magic methods 67 | |-------------------------------------------------------------------------- 68 | | 69 | | Set to false to disable write magic methods of model. 70 | | 71 | */ 72 | 73 | 'write_model_magic_where' => true, 74 | 75 | /* 76 | |-------------------------------------------------------------------------- 77 | | Write model external Eloquent builder methods 78 | |-------------------------------------------------------------------------- 79 | | 80 | | Set to false to disable write external Eloquent builder methods. 81 | | 82 | */ 83 | 84 | 'write_model_external_builder_methods' => true, 85 | 86 | /* 87 | |-------------------------------------------------------------------------- 88 | | Write model relation count properties 89 | |-------------------------------------------------------------------------- 90 | | 91 | | Set to false to disable writing of relation count properties to model DocBlocks. 92 | | 93 | */ 94 | 95 | 'write_model_relation_count_properties' => true, 96 | 97 | /* 98 | |-------------------------------------------------------------------------- 99 | | Write Eloquent model mixins 100 | |-------------------------------------------------------------------------- 101 | | 102 | | This will add the necessary DocBlock mixins to the model class 103 | | contained in the Laravel framework. This helps the IDE with 104 | | auto-completion. 105 | | 106 | | Please be aware that this setting changes a file within the /vendor directory. 107 | | 108 | */ 109 | 110 | 'write_eloquent_model_mixins' => false, 111 | 112 | /* 113 | |-------------------------------------------------------------------------- 114 | | Helper files to include 115 | |-------------------------------------------------------------------------- 116 | | 117 | | Include helper files. By default not included, but can be toggled with the 118 | | -- helpers (-H) option. Extra helper files can be included. 119 | | 120 | */ 121 | 122 | 'include_helpers' => false, 123 | 124 | 'helper_files' => [ 125 | base_path().'/vendor/laravel/framework/src/Illuminate/Support/helpers.php', 126 | ], 127 | 128 | /* 129 | |-------------------------------------------------------------------------- 130 | | Model locations to include 131 | |-------------------------------------------------------------------------- 132 | | 133 | | Define in which directories the ide-helper:models command should look 134 | | for models. 135 | | 136 | | glob patterns are supported to easier reach models in sub-directories, 137 | | e.g. `app/Services/* /Models` (without the space). 138 | | 139 | */ 140 | 141 | 'model_locations' => [ 142 | 'app', 143 | ], 144 | 145 | /* 146 | |-------------------------------------------------------------------------- 147 | | Models to ignore 148 | |-------------------------------------------------------------------------- 149 | | 150 | | Define which models should be ignored. 151 | | 152 | */ 153 | 154 | 'ignored_models' => [ 155 | // App\MyModel::class, 156 | ], 157 | 158 | /* 159 | |-------------------------------------------------------------------------- 160 | | Models hooks 161 | |-------------------------------------------------------------------------- 162 | | 163 | | Define which hook classes you want to run for models to add custom information. 164 | | 165 | | Hooks should implement Barryvdh\LaravelIdeHelper\Contracts\ModelHookInterface. 166 | | 167 | */ 168 | 169 | 'model_hooks' => [ 170 | // App\Support\IdeHelper\MyModelHook::class 171 | ], 172 | 173 | /* 174 | |-------------------------------------------------------------------------- 175 | | Extra classes 176 | |-------------------------------------------------------------------------- 177 | | 178 | | These implementations are not really extended, but called with magic functions. 179 | | 180 | */ 181 | 182 | 'extra' => [ 183 | 'Eloquent' => ['Illuminate\Database\Eloquent\Builder', 'Illuminate\Database\Query\Builder'], 184 | 'Session' => ['Illuminate\Session\Store'], 185 | ], 186 | 187 | 'magic' => [], 188 | 189 | /* 190 | |-------------------------------------------------------------------------- 191 | | Interface implementations 192 | |-------------------------------------------------------------------------- 193 | | 194 | | These interfaces will be replaced with the implementing class. Some interfaces 195 | | are detected by the helpers, others can be listed below. 196 | | 197 | */ 198 | 199 | 'interfaces' => [ 200 | // App\MyInterface::class => App\MyImplementation::class, 201 | ], 202 | 203 | /* 204 | |-------------------------------------------------------------------------- 205 | | Support for camel cased models 206 | |-------------------------------------------------------------------------- 207 | | 208 | | There are some Laravel packages (such as Eloquence) that allow for accessing 209 | | Eloquent model properties via camel case, instead of snake case. 210 | | 211 | | Enabling this option will support these packages by saving all model 212 | | properties as camel case, instead of snake case. 213 | | 214 | | For example, normally you would see this: 215 | | 216 | | * @property \Illuminate\Support\Carbon $created_at 217 | | * @property \Illuminate\Support\Carbon $updated_at 218 | | 219 | | With this enabled, the properties will be this: 220 | | 221 | | * @property \Illuminate\Support\Carbon $createdAt 222 | | * @property \Illuminate\Support\Carbon $updatedAt 223 | | 224 | | Note, it is currently an all-or-nothing option. 225 | | 226 | */ 227 | 'model_camel_case_properties' => false, 228 | 229 | /* 230 | |-------------------------------------------------------------------------- 231 | | Property casts 232 | |-------------------------------------------------------------------------- 233 | | 234 | | Cast the given "real type" to the given "type". 235 | | 236 | */ 237 | 'type_overrides' => [ 238 | 'integer' => 'int', 239 | 'boolean' => 'bool', 240 | ], 241 | 242 | /* 243 | |-------------------------------------------------------------------------- 244 | | Include DocBlocks from classes 245 | |-------------------------------------------------------------------------- 246 | | 247 | | Include DocBlocks from classes to allow additional code inspection for 248 | | magic methods and properties. 249 | | 250 | */ 251 | 'include_class_docblocks' => false, 252 | 253 | /* 254 | |-------------------------------------------------------------------------- 255 | | Force FQN usage 256 | |-------------------------------------------------------------------------- 257 | | 258 | | Use the fully qualified (class) name in DocBlocks, 259 | | even if the class exists in the same namespace 260 | | or there is an import (use className) of the class. 261 | | 262 | */ 263 | 'force_fqn' => false, 264 | 265 | /* 266 | |-------------------------------------------------------------------------- 267 | | Use generics syntax 268 | |-------------------------------------------------------------------------- 269 | | 270 | | Use generics syntax within DocBlocks, 271 | | e.g. `Collection` instead of `Collection|User[]`. 272 | | 273 | */ 274 | 'use_generics_annotations' => true, 275 | 276 | /* 277 | |-------------------------------------------------------------------------- 278 | | Additional relation types 279 | |-------------------------------------------------------------------------- 280 | | 281 | | Sometimes it's needed to create custom relation types. The key of the array 282 | | is the relationship method name. The value of the array is the fully-qualified 283 | | class name of the relationship, e.g. `'relationName' => RelationShipClass::class`. 284 | | 285 | */ 286 | 'additional_relation_types' => [], 287 | 288 | /* 289 | |-------------------------------------------------------------------------- 290 | | Additional relation return types 291 | |-------------------------------------------------------------------------- 292 | | 293 | | When using custom relation types its possible for the class name to not contain 294 | | the proper return type of the relation. The key of the array is the relationship 295 | | method name. The value of the array is the return type of the relation ('many' 296 | | or 'morphTo'). 297 | | e.g. `'relationName' => 'many'`. 298 | | 299 | */ 300 | 'additional_relation_return_types' => [], 301 | 302 | /* 303 | |-------------------------------------------------------------------------- 304 | | Run artisan commands after migrations to generate model helpers 305 | |-------------------------------------------------------------------------- 306 | | 307 | | The specified commands should run after migrations are finished running. 308 | | 309 | */ 310 | 'post_migrate' => [ 311 | // 'ide-helper:models --nowrite', 312 | ], 313 | 314 | ]; 315 | -------------------------------------------------------------------------------- /src/Concerns/HasPublishing.php: -------------------------------------------------------------------------------- 1 | shouldPublishOnCreated() && $model->isDraftTableRecord()) { 37 | $model->publish(); 38 | } 39 | }); 40 | 41 | static::updated(function (HasRevisorContract $model) { 42 | if ($model->shouldPublishOnUpdated() && $model->isDraftTableRecord()) { 43 | $model->publish(); 44 | } 45 | }); 46 | } 47 | 48 | /** 49 | * Merge the published_at and is_published casts to the model 50 | */ 51 | public function initializeHasPublishing(): void 52 | { 53 | $this->mergeCasts([ 54 | 'published_at' => 'datetime', 55 | 'is_published' => 'boolean', 56 | ]); 57 | } 58 | 59 | /** 60 | * Get a Builder instance for the Published table 61 | */ 62 | public function scopeWithPublishedContext(Builder $query): Builder 63 | { 64 | $query->getModel()->setRevisorContext(RevisorContext::Published); 65 | $query->getQuery()->from = $query->getModel()->getTable(); 66 | 67 | return $query; 68 | } 69 | 70 | /** 71 | * Publish the model. 72 | * 73 | * Sets the draft record to a published state. 74 | * Copies the draft record to the published table. 75 | * Saves the updated draft record. 76 | */ 77 | public function publish(): static|bool 78 | { 79 | if ($this->fireModelEvent('publishing') === false) { 80 | return false; 81 | } 82 | 83 | // put the draft record in published state 84 | $this->setPublishedAttributes(); 85 | 86 | // copy the draft record to the published table 87 | $this->applyStateToPublishedRecord(); 88 | 89 | // save the draft record 90 | $this->saveQuietly(); 91 | 92 | // fire the published event 93 | $this->fireModelEvent('published'); 94 | 95 | $this->refresh(); 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * Unpublish the model. 102 | * 103 | * Sets the draft record to an unpublished state. 104 | * Deletes the corresponding record from the published table. 105 | * Saves the updated draft record. 106 | * Fires the unpublished event. 107 | */ 108 | public function unpublish(): static 109 | { 110 | if ($this->fireModelEvent('unpublishing') === false) { 111 | return $this; 112 | } 113 | 114 | // put the draft record in unpublished state 115 | $this->setUnpublishedAttributes(); 116 | 117 | // delete the published record 118 | if (method_exists($this, 'forceDeleteQuietly')) { 119 | $this->publishedRecord?->forceDeleteQuietly(); 120 | } else { 121 | $this->publishedRecord?->deleteQuietly(); 122 | } 123 | 124 | // save the draft record 125 | $this->saveQuietly(); 126 | 127 | // fire the unpublished event 128 | $this->fireModelEvent('unpublished'); 129 | 130 | $this->refresh(); 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * Set the published attributes on the model. 137 | * 138 | * Updates the published_at timestamp, sets is_published to true, 139 | * and associates the current authenticated user as the publisher. 140 | */ 141 | public function setPublishedAttributes(): static 142 | { 143 | $this->published_at = now(); 144 | $this->is_published = true; 145 | $this->publisher()->associate(auth()->user()); 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * Apply the state of this record to the published record 152 | */ 153 | public function applyStateToPublishedRecord(): static 154 | { 155 | // find or make the published record 156 | $published = $this->publishedRecord ?? static::make()->setRevisorContext(RevisorContext::Published); 157 | 158 | // Temporarily unhide hidden attributes so they can be copied 159 | $hiddenAttributes = $this->getHidden(); 160 | $this->setHidden([]); 161 | 162 | // copy the attributes from the draft record to the published record 163 | $published->forceFill($this->attributesToArray()); 164 | 165 | // Restore hidden attributes 166 | $this->setHidden($hiddenAttributes); 167 | 168 | // save the published record 169 | $published->save(); 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Set the publishing related attributes on 176 | * the model to their unpublished state 177 | */ 178 | public function setUnpublishedAttributes(): static 179 | { 180 | $this->published_at = null; 181 | $this->is_published = false; 182 | $this->publisher()->dissociate(); 183 | 184 | return $this; 185 | } 186 | 187 | /** 188 | * Get the published record for this model 189 | * 190 | * @throws Exception 191 | */ 192 | public function publishedRecord(): HasOne 193 | { 194 | if ($this->isPublishedTableRecord()) { 195 | throw new Exception('The published record HasOne relationship is only available to Draft and Version records'); 196 | } 197 | 198 | $instance = (new static)->withPublishedContext(); 199 | $localKey = $this->isVersionTableRecord() ? 'record_id' : $this->getKeyName(); 200 | 201 | return $this->newHasOne( 202 | $instance, $this, $instance->getModel()->getTable().'.'.$this->getKeyName(), $localKey 203 | ); 204 | } 205 | 206 | /** 207 | * Get the publisher relationship for this model 208 | */ 209 | public function publisher(): MorphTo 210 | { 211 | return $this->morphTo(config('revisor.publishing.table_columns.publisher')); 212 | } 213 | 214 | /** 215 | * Get the name of the publisher for this model 216 | */ 217 | public function getPublisherNameAttribute(): ?string 218 | { 219 | if (! $this->publisher) { 220 | return null; 221 | } 222 | 223 | return $this->publisher->name ?? 224 | $this->publisher->title ?? 225 | $this->publisher->email ?? 226 | $this->publisher->username ?? 227 | class_basename($this->publisher).' '.$this->publisher->getKey(); 228 | } 229 | 230 | /** 231 | * Set whether to publish the record when a new instance of the model is created 232 | */ 233 | public function publishOnCreated(bool $bool = true): static 234 | { 235 | $this->publishOnCreated = $bool; 236 | 237 | return $this; 238 | } 239 | 240 | /** 241 | * Set whether to publish the record when an instance of the model is updated 242 | */ 243 | public function publishOnUpdated(bool $bool = true): static 244 | { 245 | $this->publishOnUpdated = $bool; 246 | 247 | return $this; 248 | } 249 | 250 | /** 251 | * Set whether to publish the record when an instance of the model is created or updated 252 | */ 253 | public function publishOnSaved(bool $bool = true): static 254 | { 255 | $this->publishOnCreated = $bool; 256 | $this->publishOnUpdated = $bool; 257 | 258 | return $this; 259 | } 260 | 261 | /** 262 | * Get whether to publish the record when a new instance of the model is created 263 | */ 264 | public function shouldPublishOnCreated(): bool 265 | { 266 | return is_null($this->publishOnCreated) ? config('revisor.publishing.publish_on_created') : $this->publishOnCreated; 267 | } 268 | 269 | /** 270 | * Get whether to publish the record when an instance of the model is updated 271 | */ 272 | public function shouldPublishOnUpdated(): bool 273 | { 274 | return is_null($this->publishOnUpdated) ? config('revisor.publishing.publish_on_updated') : $this->publishOnUpdated; 275 | } 276 | 277 | /** 278 | * Get the Published table name for the model 279 | */ 280 | public function getPublishedTable(): string 281 | { 282 | return Revisor::getPublishedTableFor($this->getBaseTable()); 283 | } 284 | 285 | /** 286 | * Check if the model is a Published table record 287 | */ 288 | public function isPublishedTableRecord(): bool 289 | { 290 | return $this->getTable() === $this->getPublishedTable(); 291 | } 292 | 293 | public function isPublished(): bool 294 | { 295 | return $this->is_published; 296 | } 297 | 298 | public function isRevised(): bool 299 | { 300 | return $this->updated_at > $this->published_at; 301 | } 302 | 303 | public function isUnpublishedOrRevised(): bool 304 | { 305 | return $this->updated_at > $this->published_at || $this->is_published === false; 306 | } 307 | 308 | /** 309 | * Register a "publishing" model event callback with the dispatcher. 310 | */ 311 | public static function publishing(string|Closure $callback): void 312 | { 313 | static::registerModelEvent('publishing', $callback); 314 | } 315 | 316 | /** 317 | * Register a "published" model event callback with the dispatcher. 318 | */ 319 | public static function published(string|Closure $callback): void 320 | { 321 | static::registerModelEvent('published', $callback); 322 | } 323 | 324 | /** 325 | * Register a "unpublishing" model event callback with the dispatcher. 326 | */ 327 | public static function unpublishing(string|Closure $callback): void 328 | { 329 | static::registerModelEvent('unpublishing', $callback); 330 | } 331 | 332 | /** 333 | * Register a "unpublished" model event callback with the dispatcher. 334 | */ 335 | public static function unpublished(string|Closure $callback): void 336 | { 337 | static::registerModelEvent('unpublished', $callback); 338 | } 339 | 340 | public function scopePublished(Builder $query): Builder 341 | { 342 | return $query->where('is_published', 1); 343 | } 344 | 345 | public function scopeUnpublished(Builder $query): Builder 346 | { 347 | return $query->where('is_published', 0); 348 | } 349 | 350 | public function scopeUnpublishedOrRevised(Builder $query): Builder 351 | { 352 | return $query->where('updated_at', '>', 'published_at') 353 | ->orWhere('is_published', 0); 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /src/Concerns/HasVersioning.php: -------------------------------------------------------------------------------- 1 | isDraftTableRecord()) { 43 | return; 44 | } 45 | 46 | if ($model->shouldSaveNewVersionOnCreated()) { 47 | $model->saveNewVersion(); 48 | } 49 | }); 50 | 51 | static::updated(function (HasRevisorContract $model) { 52 | if (! $model->isDraftTableRecord()) { 53 | return; 54 | } 55 | 56 | if ($model->shouldSaveNewVersionOnUpdated()) { 57 | $model->saveNewVersion(); 58 | } else { 59 | $model->syncToCurrentVersionRecord(); 60 | } 61 | }); 62 | 63 | static::saving(function (HasRevisorContract $model) { 64 | if ($model->isVersionTableRecord()) { 65 | return; 66 | } 67 | 68 | $model->is_current = true; 69 | }); 70 | 71 | static::published(function (HasRevisorContract $model) { 72 | $model->syncToCurrentVersionRecord(); 73 | }); 74 | 75 | static::unpublished(function (HasRevisorContract $model) { 76 | $model->syncToCurrentVersionRecord(); 77 | }); 78 | } 79 | 80 | /** 81 | * Merge the is_current cast to the model 82 | */ 83 | public function initializeHasVersioning(): void 84 | { 85 | $this->mergeCasts([ 86 | 'is_current' => 'boolean', 87 | ]); 88 | } 89 | 90 | /** 91 | * Create a new version of this record in the version table 92 | * Mark the new version as the current version and not published 93 | * Update this record to have the version number of the new version 94 | * Prune old versions if necessary 95 | */ 96 | public function saveNewVersion(): static|bool 97 | { 98 | if ($this->fireModelEvent('savingNewVersion') === false) { 99 | return false; 100 | } 101 | 102 | $exceptAttributes = collect(config('revisor.publishing.table_columns')) 103 | ->values() 104 | ->add('id') 105 | ->toArray(); 106 | 107 | // Temporarily unhide hidden attributes so they can be copied 108 | $hiddenAttributes = $this->getHidden(); 109 | $this->setHidden([]); 110 | 111 | $attributes = collect($this->attributesToArray()) 112 | ->except($exceptAttributes) 113 | ->merge([ 114 | 'record_id' => $this->id, 115 | 'version_number' => ($this->versionRecords()->max('version_number') ?? 0) + 1, 116 | ]) 117 | ->toArray(); 118 | 119 | // Restore hidden attributes 120 | $this->setHidden($hiddenAttributes); 121 | 122 | $version = static::make()->setRevisorContext(RevisorContext::Version)->forceFill($attributes); 123 | $this->setVersionAsCurrent($version); 124 | 125 | $this->pruneVersions(); 126 | 127 | $this->fireModelEvent('savedNewVersion', $version); 128 | 129 | return $this; 130 | } 131 | 132 | /** 133 | * Rollback the Draft table record to the given version 134 | */ 135 | public function revertToVersion(HasRevisorContract|int|string $version): static 136 | { 137 | $version = ! is_object($version) ? $this->versionRecords()->find($version) : $version; 138 | 139 | $this->fireModelEvent('revertingToVersion', $version); 140 | 141 | // set the version as current and save it 142 | $this->setVersionAsCurrent($version); 143 | 144 | // update the current draft record to have the data from the version 145 | // excluding the publishing and versioning columns 146 | // and updating the updated_at timestamp 147 | $attributes = collect($version->attributesToArray()) 148 | ->except([ 149 | 'id', 150 | config('revisor.versioning.table_columns.record_id'), 151 | config('revisor.publishing.table_columns.published_at'), 152 | config('revisor.publishing.table_columns.is_published'), 153 | config('revisor.publishing.table_columns.publisher').'_id', 154 | config('revisor.publishing.table_columns.publisher').'_type', 155 | ]) 156 | ->put('updated_at', now()) 157 | ->toArray(); 158 | 159 | $this->forceFill($attributes)->saveQuietly(); 160 | 161 | $this->fireModelEvent('revertedToVersion', $version); 162 | 163 | return $this->refresh(); 164 | } 165 | 166 | public function revertToVersionNumber(int $versionNumber): static 167 | { 168 | $version = $this->versionRecords()->firstWhere('version_number', $versionNumber); 169 | 170 | return $this->revertToVersion($version); 171 | } 172 | 173 | /** 174 | * Revert the Draft record to the state of this Version record 175 | * 176 | * @throws Exception if this record is not a Version record 177 | */ 178 | public function revertDraftToThisVersion(): static 179 | { 180 | if (! $this->isVersionTableRecord()) { 181 | $context = $this->getRevisorContext(); 182 | throw new Exception("Can not revert this record, it is a $context record. Only Version records can be reverted."); 183 | } 184 | 185 | $this->draftRecord->revertToVersion($this); 186 | 187 | return $this; 188 | } 189 | 190 | public function setVersionAsCurrent(HasRevisorContract|int|string $version): static 191 | { 192 | $version = ! is_object($version) ? $this->versionRecords()->find($version) : $version; 193 | 194 | // update all other versions to not be current 195 | // and set this version as current and save it 196 | $this->versionRecords()->where('is_current', 1)->update(['is_current' => 0]); 197 | $version->forceFill(['is_current' => 1])->saveQuietly(); 198 | 199 | // update the draft record to have the new version_number 200 | if ($this->version_number !== $version->version_number) { 201 | $this->forceFill([ 202 | config('revisor.versioning.table_columns.version_number') => $version->{config('revisor.versioning.table_columns.version_number')}, 203 | ])->saveQuietly(); 204 | } 205 | 206 | $this->refresh(); 207 | 208 | return $this; 209 | } 210 | 211 | public function versionRecords(): HasMany 212 | { 213 | $instance = $this->newRelatedInstance(static::class)->setRevisorContext(RevisorContext::Version); 214 | 215 | return $this->newHasMany( 216 | $instance->newQuery(), $this, $this->getVersionTable().'.record_id', $this->getKeyName() 217 | ); 218 | } 219 | 220 | public function currentVersionRecord(): HasOne 221 | { 222 | $query = $this->newRelatedInstance(static::class)->withVersionContext(); 223 | 224 | return $this->newHasOne( 225 | $query, $this, $query->getModel()->getTable().'.record_id', $this->getKeyName() 226 | )->where('is_current', 1); 227 | } 228 | 229 | public function keepVersions(null|int|bool $keep = true): void 230 | { 231 | $this->keepVersions = $keep; 232 | } 233 | 234 | public function shouldKeepVersions(): int|bool 235 | { 236 | if ($this->keepVersions === null) { 237 | return config('revisor.versioning.keep_versions'); 238 | } 239 | 240 | return $this->keepVersions; 241 | } 242 | 243 | public function prunableVersions(): HasMany 244 | { 245 | $keep = $this->shouldKeepVersions(); 246 | 247 | // int = prune the oldest, keeping n versions 248 | if (is_int($keep)) { 249 | return $this->versionRecords()->where('is_current', 0) 250 | ->orderBy('version_number') 251 | ->skip($keep) 252 | ->take(PHP_INT_MAX); 253 | } 254 | 255 | // false = prune all revisions 256 | if ($keep === false) { 257 | return $this->versionRecords(); 258 | } 259 | 260 | // true = avoid pruning entirely by returning no prunable versions 261 | return $this->versionRecords()->whereRaw('1 = 0'); 262 | } 263 | 264 | /** 265 | * Sync this record's attributes to the current version record 266 | * Create a new version record if there is no current version 267 | */ 268 | public function syncToCurrentVersionRecord(): static|bool 269 | { 270 | if (! $this->currentVersionRecord) { 271 | return $this->saveNewVersion(); 272 | } 273 | $this->fireModelEvent('syncingToCurrentVersion', $this->currentVersionRecord); 274 | 275 | $attributes = collect($this->attributesToArray()) 276 | ->except([$this->getKeyName(), 'version_number']) 277 | ->toArray(); 278 | 279 | $this->currentVersionRecord->forceFill($attributes)->saveQuietly(); 280 | 281 | // if this current version is published, ensure no 282 | // other versions are marked as published 283 | if ($this->currentVersionRecord->is_published) { 284 | $this->versionRecords() 285 | ->whereNot('id', $this->currentVersionRecord->id) 286 | ->update(['is_published' => 0]); 287 | } 288 | 289 | $this->fireModelEvent('syncedToCurrentVersion', $this->currentVersionRecord); 290 | 291 | return $this; 292 | } 293 | 294 | public function pruneVersions(): static 295 | { 296 | if (! $this->prunableVersions->count()) { 297 | return $this; 298 | } 299 | 300 | if (method_exists($this, 'forceDeleteQuietly')) { 301 | $this->prunableVersions->each->forceDelete(); 302 | } else { 303 | $this->prunableVersions->each->delete(); 304 | } 305 | 306 | return $this; 307 | } 308 | 309 | /** 310 | * Get a Builder instance for the Version table 311 | */ 312 | public function scopeWithVersionContext(Builder $query): Builder 313 | { 314 | $query->getModel()->setRevisorContext(RevisorContext::Version); 315 | $query->getQuery()->from = $query->getModel()->getTable(); 316 | 317 | return $query; 318 | } 319 | 320 | public function saveNewVersionOnCreated(bool $bool = true): static 321 | { 322 | $this->saveNewVersionOnCreated = $bool; 323 | 324 | return $this; 325 | } 326 | 327 | public function shouldSaveNewVersionOnCreated(): bool 328 | { 329 | return is_null($this->saveNewVersionOnCreated) ? 330 | config('revisor.versioning.save_new_version_on_created') : 331 | $this->saveNewVersionOnCreated; 332 | } 333 | 334 | public function saveNewVersionOnUpdated(bool $bool = true): static 335 | { 336 | $this->saveNewVersionOnUpdated = $bool; 337 | 338 | return $this; 339 | } 340 | 341 | public function shouldSaveNewVersionOnUpdated(): bool 342 | { 343 | return is_null($this->saveNewVersionOnUpdated) ? 344 | config('revisor.versioning.save_new_version_on_updated') : 345 | $this->saveNewVersionOnUpdated; 346 | } 347 | 348 | public function saveNewVersionOnSaved(bool $bool = true): static 349 | { 350 | $this->saveNewVersionOnCreated = $bool; 351 | $this->saveNewVersionOnUpdated = $bool; 352 | 353 | return $this; 354 | } 355 | 356 | public function getVersionTable(): string 357 | { 358 | return Revisor::getVersionTableFor($this->getBaseTable()); 359 | } 360 | 361 | public function isVersionTableRecord(): bool 362 | { 363 | return $this->getTable() === $this->getVersionTable(); 364 | } 365 | 366 | /** 367 | * Register a "savingNewVersion" model event callback with the dispatcher. 368 | */ 369 | public static function savingNewVersion(string|Closure $callback): void 370 | { 371 | static::registerModelEvent('savingNewVersion', $callback); 372 | } 373 | 374 | /** 375 | * Register a "savedNewVersion" model event callback with the dispatcher. 376 | */ 377 | public static function savedNewVersion(string|Closure $callback): void 378 | { 379 | static::registerModelEvent('savedNewVersion', $callback); 380 | } 381 | 382 | /** 383 | * Register a "syncingToCurrentVersion" model event callback with the dispatcher. 384 | */ 385 | public static function syncingToCurrentVersion(string|Closure $callback): void 386 | { 387 | static::registerModelEvent('syncingToCurrentVersion', $callback); 388 | } 389 | 390 | /** 391 | * Register a "syncedToCurrentVersion" model event callback with the dispatcher. 392 | */ 393 | public static function syncedToCurrentVersion(string|Closure $callback): void 394 | { 395 | static::registerModelEvent('syncedToCurrentVersion', $callback); 396 | } 397 | 398 | /** 399 | * Register a "revertingToVersion" model event callback with the dispatcher. 400 | */ 401 | public static function revertingToVersion(string|Closure $callback): void 402 | { 403 | static::registerModelEvent('revertingToVersion', $callback); 404 | } 405 | 406 | /** 407 | * Register a "revertedToVersion" model event callback with the dispatcher. 408 | */ 409 | public static function revertedToVersion(string|Closure $callback): void 410 | { 411 | static::registerModelEvent('revertedToVersion', $callback); 412 | } 413 | } 414 | --------------------------------------------------------------------------------