├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── pending-updates.php ├── database └── migrations │ └── create_pending_updates_table.php └── src ├── Commands └── CheckPendingUpdates.php ├── Exceptions ├── InvalidAttributeException.php ├── InvalidPendingParametersException.php └── InvalidPendingUpdateModel.php ├── Models ├── Concerns │ └── HasPendingUpdates.php └── PendingUpdate.php ├── PendingUpdateServiceProvider.php └── Support └── Postponer.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-update-postponer` will be documented in this file. 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) stfndamjanovic 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 | # Postpone model updates or temporarily keep them updated for some time 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/stfn/laravel-pending-updates.svg?style=flat-square)](https://packagist.org/packages/stfn/laravel-pending-updates) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/stfndamjanovic/laravel-pending-updates/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/stfndamjanovic/laravel-pending-updates/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/stfndamjanovic/laravel-pending-updates/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/stfndamjanovic/laravel-pending-updates/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 6 | 7 | When updating an Eloquent model, by using this package, you can postpone updating process for some time. 8 | 9 | ```php 10 | $news = News::find(1); 11 | 12 | $news->postpone() 13 | ->startFrom('2023-01-01 00:00:00') 14 | ->keepForHours(24) 15 | ->update(['is_active' => true]); 16 | ``` 17 | The model itself will not be updated in this case. The package will just schedule an update for you. 18 | So this news will be active only on 1st January for the whole day and after that, the package will revert the news to its previous state. 19 | 20 | ## Installation 21 | 22 | You can install the package via composer: 23 | 24 | ```bash 25 | composer require stfn/laravel-pending-updates 26 | ``` 27 | 28 | You can publish and run the migrations with: 29 | 30 | ```bash 31 | php artisan vendor:publish --tag="pending-updates-migrations" 32 | php artisan migrate 33 | ``` 34 | 35 | You can publish the config file with: 36 | 37 | ```bash 38 | php artisan vendor:publish --tag="pending-updates-config" 39 | ``` 40 | 41 | This is the contents of the published config file: 42 | 43 | ```php 44 | return [ 45 | // Maximum postpone in days. 46 | 'max_postpone_days' => 10, 47 | 48 | // The model uses to store pending updates. 49 | 'model' => \Stfn\PendingUpdates\Models\PendingUpdate::class, 50 | ]; 51 | 52 | ``` 53 | When running the console command `pending-updates:check` all pending updates will be checked 54 | and if there is a need to revert some update to original table, this command will do that for you. 55 | 56 | That command needs to be scheduled in the Laravel console kernel. 57 | ```php 58 | // app/Console/Kernel.php 59 | use Stfn\PendingUpdates\Commands\CheckPendingUpdates; 60 | 61 | protected function schedule(Schedule $schedule) 62 | { 63 | $schedule->command(CheckPendingUpdates::class)->everyMinute(); 64 | } 65 | ``` 66 | 67 | ## Usage 68 | You should add the HasPendingUpdates trait to all models which need to have a pending update option. 69 | 70 | ```php 71 | use Illuminate\Database\Eloquent\Model; 72 | use Stfn\PendingUpdates\Models\Concerns\HasPendingUpdates; 73 | 74 | class Ticket extends Model 75 | { 76 | use HasPendingUpdates; 77 | } 78 | ``` 79 | 80 | With this in place, you will be able to postpone update of this model. 81 | 82 | ### Using keep for 83 | 84 | By using keep for, update will be performed at the moment, but package will revert changes after specific number of minutes, hours or days. 85 | 86 | ```php 87 | $ticket = Ticket::find(1); 88 | 89 | // Update ticket price to 200 and keep it updated for 60 minutes. 90 | $ticket->postpone() 91 | ->keepForMinutes(60) 92 | ->update(['price' => 200]); 93 | 94 | // Update ticket price to 200 and keep it updated for 12 hours. 95 | $ticket->postpone() 96 | ->keepForHours(12) 97 | ->update(['price' => 200]); 98 | 99 | // Update ticket price to 200 and keep it updated for 3 days. 100 | $ticket->postpone() 101 | ->keepForDays(3) 102 | ->update(['price' => 200]); 103 | ``` 104 | 105 | ### Using delay for 106 | 107 | By using delay for, update will be performed later, after specific number of minutes, hours or days. 108 | 109 | ```php 110 | $ticket = Ticket::find(1); 111 | 112 | // Update ticket price to 200 after 60 minutes from now and keep it like that for unlimited time. 113 | $ticket->postpone() 114 | ->delayForMinutes(60) 115 | ->update(['price' => 200]); 116 | 117 | // Update ticket price to 200 after 12 hours from now and keep it like that for unlimited time. 118 | $ticket->postpone() 119 | ->delayForHours(12) 120 | ->update(['price' => 200]); 121 | 122 | // Update ticket price to 200 after 3 days from now and keep it like that for unlimited time. 123 | $ticket->postpone() 124 | ->delayForDays(3) 125 | ->update(['price' => 200]); 126 | ``` 127 | 128 | ### Using timestamps 129 | 130 | You can also use timestamps to specify exact time when you want to update some model. 131 | 132 | ```php 133 | $product = Product::find(1); 134 | 135 | // Update product to be unavailable from 1st January. 136 | $product->postpone() 137 | ->startFrom("2023-01-01 00:00:00") 138 | ->update(['is_available' => false]); 139 | 140 | // Update product to be unavailable until 4th January. 141 | $product->postpone() 142 | ->revertAt("2023-04-01 00:00:00") 143 | ->update(['is_available' => false]); 144 | 145 | // Update product to be unavailable from 1st January to 4th January. 146 | $product->postpone() 147 | ->startFrom("2023-01-01 00:00:00") 148 | ->revertAt("2023-04-01 00:00:00") 149 | ->update(['is_available' => false]); 150 | ``` 151 | 152 | ### Using combination 153 | 154 | A combination of specific minutes, hours, or days with timestamps is also possible. 155 | ```php 156 | $product = Product::find(1); 157 | 158 | // Update product to be unavailable from 1st January and keep that state for 1 day. 159 | $product->postpone() 160 | ->startFrom("2023-01-01 00:00:00") 161 | ->keepForDays(1) 162 | ->update(['is_available' => false]); 163 | 164 | // Update product to became unavailable after 60 minutes from now and keep that state until 4th January. 165 | $product->postpone() 166 | ->delayForMinutes(60) 167 | ->revertAt("2023-04-01 00:00:00") 168 | ->update(['price' => 200]); 169 | ``` 170 | 171 | ### Using methods on the model 172 | 173 | By default, all fillable attributes are allowed to be postponed, but you can change that by overriding 174 | `allowedPendingAttributes` method. 175 | 176 | ```php 177 | use Illuminate\Database\Eloquent\Model; 178 | use Stfn\PendingUpdates\Models\Concerns\HasPendingUpdates; 179 | 180 | class Ticket extends Model 181 | { 182 | use HasPendingUpdates; 183 | 184 | public function allowedPendingAttributes() 185 | { 186 | return ['price']; 187 | } 188 | } 189 | ``` 190 | Keep in mind that those fields also need to be fillable. 191 | 192 | Sometimes scheduled update may fail, for various reasons. 193 | By default, the package will send that exception to your configured external 194 | report service like Sentry, Flare, or Bugsnag and after that, 195 | the record from the `pending_updates` table will be removed. If you want to change this behavior, you can 196 | override the `updateCannotBeApplied` method on the `PendingUpdateModel`. 197 | 198 | ```php 199 | use Illuminate\Database\Eloquent\Model; 200 | use Stfn\PendingUpdates\Models\PendingUpdate; 201 | 202 | class CustomPendingUpdate extends PendingUpdate 203 | { 204 | public function updateCannotBeApplied($exception, $model) 205 | { 206 | // Your custom logic here 207 | } 208 | } 209 | ``` 210 | 211 | ## Testing 212 | 213 | ```bash 214 | composer test 215 | ``` 216 | 217 | ## Changelog 218 | 219 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 220 | 221 | ## Credits 222 | 223 | - [Stefan Damjanovic](https://github.com/stfndamjanovic) 224 | - [All Contributors](../../contributors) 225 | 226 | ## License 227 | 228 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 229 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stfn/laravel-pending-updates", 3 | "description": "", 4 | "keywords": [ 5 | "stfndamjanovic", 6 | "laravel", 7 | "laravel-pending-updates" 8 | ], 9 | "homepage": "https://github.com/stfndamjanovic/laravel-pending-updates", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Stefan Damjanovic", 14 | "email": "damjanovicstefan0@gmail.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "spatie/laravel-package-tools": "^1.14.0", 21 | "illuminate/contracts": "^9.0|^10.0" 22 | }, 23 | "require-dev": { 24 | "laravel/pint": "^1.0", 25 | "nunomaduro/collision": "^6.0", 26 | "nunomaduro/larastan": "^2.0.1", 27 | "orchestra/testbench": "^7.0|^8.0", 28 | "pestphp/pest": "^1.21", 29 | "pestphp/pest-plugin-laravel": "^1.1", 30 | "phpstan/extension-installer": "^1.1", 31 | "phpstan/phpstan-deprecation-rules": "^1.0", 32 | "phpstan/phpstan-phpunit": "^1.0", 33 | "phpunit/phpunit": "^9.5", 34 | "spatie/pest-plugin-test-time": "^1.1" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "Stfn\\PendingUpdates\\": "src" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Stfn\\PendingUpdates\\Tests\\": "tests" 44 | } 45 | }, 46 | "scripts": { 47 | "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", 48 | "analyse": "vendor/bin/phpstan analyse", 49 | "test": "vendor/bin/pest", 50 | "test-coverage": "vendor/bin/pest --coverage", 51 | "format": "vendor/bin/pint" 52 | }, 53 | "config": { 54 | "sort-packages": true, 55 | "allow-plugins": { 56 | "pestphp/pest-plugin": true, 57 | "phpstan/extension-installer": true 58 | } 59 | }, 60 | "extra": { 61 | "laravel": { 62 | "providers": [ 63 | "Stfn\\PendingUpdates\\PendingUpdateServiceProvider" 64 | ] 65 | } 66 | }, 67 | "minimum-stability": "dev", 68 | "prefer-stable": true 69 | } 70 | -------------------------------------------------------------------------------- /config/pending-updates.php: -------------------------------------------------------------------------------- 1 | 10, 6 | 7 | // The model uses to store pending updates. 8 | 'model' => \Stfn\PendingUpdates\Models\PendingUpdate::class, 9 | ]; 10 | -------------------------------------------------------------------------------- /database/migrations/create_pending_updates_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->morphs('parent'); 14 | $table->json('values'); 15 | $table->timestamp('start_at')->nullable(); 16 | $table->timestamp('revert_at')->nullable(); 17 | $table->timestamps(); 18 | }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/Commands/CheckPendingUpdates.php: -------------------------------------------------------------------------------- 1 | orWhere('revert_at', '<=', now()) 19 | ->get() 20 | ->each(function ($pendingUpdate) { 21 | if ($pendingUpdate->shouldRevert()) { 22 | $pendingUpdate->revert(); 23 | } 24 | 25 | if ($pendingUpdate->shouldApply()) { 26 | $pendingUpdate->apply(); 27 | } 28 | }); 29 | 30 | return self::SUCCESS; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidAttributeException.php: -------------------------------------------------------------------------------- 1 | pendingUpdates()->delete(); 15 | }); 16 | } 17 | 18 | public function pendingUpdates() 19 | { 20 | return $this->morphMany($this->getPendingUpdateModelClass(), 'parent'); 21 | } 22 | 23 | public function postpone() 24 | { 25 | return new Postponer($this); 26 | } 27 | 28 | protected function getPendingUpdateModelClass() 29 | { 30 | return config('pending-updates.model'); 31 | } 32 | 33 | public function hasPendingUpdates() 34 | { 35 | return $this->pendingUpdates()->exists(); 36 | } 37 | 38 | public function allowedPendingAttributes() 39 | { 40 | return $this->getFillable(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Models/PendingUpdate.php: -------------------------------------------------------------------------------- 1 | 'array', 20 | ]; 21 | 22 | public $table = 'pending_updates'; 23 | 24 | public function parent() 25 | { 26 | return $this->morphTo(); 27 | } 28 | 29 | public function revert() 30 | { 31 | if (! $this->parent instanceof Model) { 32 | $this->delete(); 33 | 34 | return; 35 | } 36 | 37 | try { 38 | $this->revertParentModel(); 39 | } catch (\Exception $exception) { 40 | $this->updateCannotBeApplied($exception, $this->parent); 41 | 42 | return; 43 | } 44 | 45 | $this->delete(); 46 | } 47 | 48 | public function apply() 49 | { 50 | if (! $this->parent instanceof Model) { 51 | $this->delete(); 52 | 53 | return; 54 | } 55 | 56 | $parentAttributes = array_intersect_key($this->getParentAttributes(), $this->values); 57 | 58 | try { 59 | $this->revertParentModel(); 60 | } catch (\Exception $exception) { 61 | $this->updateCannotBeApplied($exception, $this->parent); 62 | 63 | return; 64 | } 65 | 66 | if (! $this->revert_at) { 67 | $this->delete(); 68 | 69 | return; 70 | } 71 | 72 | $this->update(['start_at' => null, 'values' => $parentAttributes]); 73 | } 74 | 75 | public function getParentAttributes() 76 | { 77 | return $this->parent->getAttributes(); 78 | } 79 | 80 | protected function revertParentModel() 81 | { 82 | return $this->parent->forceFill($this->values)->save(); 83 | } 84 | 85 | public function shouldRevert() 86 | { 87 | return Carbon::now()->gt($this->revert_at); 88 | } 89 | 90 | public function shouldApply() 91 | { 92 | return Carbon::now()->gt($this->start_at); 93 | } 94 | 95 | public function updateCannotBeApplied($exception, $model) 96 | { 97 | report($exception); 98 | 99 | $this->delete(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/PendingUpdateServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-pending-updates') 17 | ->hasConfigFile('pending-updates') 18 | ->hasMigration('create_pending_updates_table') 19 | ->hasCommand(CheckPendingUpdates::class); 20 | } 21 | 22 | public function packageBooted() 23 | { 24 | $model = config('pending-updates.model'); 25 | 26 | if (! is_a($model, PendingUpdate::class, true)) { 27 | throw InvalidPendingUpdateModel::create($model); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Support/Postponer.php: -------------------------------------------------------------------------------- 1 | model = $model; 27 | } 28 | 29 | public function keepForMinutes(int $minutes) 30 | { 31 | return $this->setKeepForProperty($this->minutesToSeconds($minutes)); 32 | } 33 | 34 | public function keepForHours(int $hours) 35 | { 36 | return $this->setKeepForProperty($this->hoursToSeconds($hours)); 37 | } 38 | 39 | public function keepForDays(int $days) 40 | { 41 | return $this->setKeepForProperty($this->daysToSeconds($days)); 42 | } 43 | 44 | public function delayForMinutes(int $minutes) 45 | { 46 | return $this->setDelayForProperty($this->minutesToSeconds($minutes)); 47 | } 48 | 49 | public function delayForHours(int $hours) 50 | { 51 | return $this->setDelayForProperty($this->hoursToSeconds($hours)); 52 | } 53 | 54 | public function delayForDays(int $days) 55 | { 56 | return $this->setDelayForProperty($this->daysToSeconds($days)); 57 | } 58 | 59 | public function startFrom(string $timestamp) 60 | { 61 | $date = Carbon::parse($timestamp); 62 | 63 | $this->validateTimestamp($date); 64 | 65 | if ($this->startAt) { 66 | throw InvalidPendingParametersException::twicePropertySet(); 67 | } 68 | 69 | $this->startAt = $date->format(self::DATE_FORMAT); 70 | 71 | return $this; 72 | } 73 | 74 | public function revertAt(string $timestamp) 75 | { 76 | $date = Carbon::parse($timestamp); 77 | 78 | $this->validateTimestamp($date); 79 | 80 | if ($this->revertAt) { 81 | throw InvalidPendingParametersException::twicePropertySet(); 82 | } 83 | 84 | $this->revertAt = $date->format(self::DATE_FORMAT); 85 | 86 | return $this; 87 | } 88 | 89 | protected function validateTimestamp(Carbon $timestamp) 90 | { 91 | if ($timestamp->isPast()) { 92 | throw InvalidPendingParametersException::pastTimestamp(); 93 | } 94 | } 95 | 96 | protected function setDelayForProperty(int $seconds) 97 | { 98 | if ($this->delayFor) { 99 | throw InvalidPendingParametersException::twicePropertySet(); 100 | } 101 | 102 | if ($seconds <= 0) { 103 | throw InvalidPendingParametersException::negativeTimeConfiguration(); 104 | } 105 | 106 | $this->delayFor = $seconds; 107 | 108 | return $this; 109 | } 110 | 111 | protected function setKeepForProperty(int $seconds) 112 | { 113 | if ($this->keepFor) { 114 | throw InvalidPendingParametersException::twicePropertySet(); 115 | } 116 | 117 | if ($seconds <= 0) { 118 | throw InvalidPendingParametersException::negativeTimeConfiguration(); 119 | } 120 | 121 | $this->keepFor = $seconds; 122 | 123 | return $this; 124 | } 125 | 126 | protected function minutesToSeconds(int $minutes) 127 | { 128 | return $minutes * 60; 129 | } 130 | 131 | protected function hoursToSeconds(int $hours) 132 | { 133 | return $hours * 60 * 60; 134 | } 135 | 136 | protected function daysToSeconds(int $days) 137 | { 138 | return $days * 60 * 60 * 24; 139 | } 140 | 141 | protected function get() 142 | { 143 | if ($this->startAt && $this->delayFor) { 144 | throw InvalidPendingParametersException::invalidStartAtConfiguration(); 145 | } 146 | 147 | if ($this->revertAt && $this->keepFor) { 148 | throw InvalidPendingParametersException::invalidRevertAtConfiguration(); 149 | } 150 | 151 | $startAt = $this->startAt; 152 | $revertAt = $this->revertAt; 153 | 154 | if ($this->delayFor) { 155 | $startAt = Carbon::now() 156 | ->addSeconds($this->delayFor) 157 | ->format(self::DATE_FORMAT); 158 | } 159 | 160 | if ($this->keepFor) { 161 | $revertAt = Carbon::parse($startAt) 162 | ->addSeconds($this->keepFor) 163 | ->format(self::DATE_FORMAT); 164 | } 165 | 166 | if (($startAt && $revertAt) && $startAt >= $revertAt) { 167 | throw InvalidPendingParametersException::invalidCombinationOfStartAndRevertAt(); 168 | } 169 | 170 | if (! $startAt && ! $revertAt) { 171 | throw InvalidPendingParametersException::invalidTimestampConfiguration(); 172 | } 173 | 174 | if (Carbon::parse($revertAt)->diffInDays(now()) > config('pending-updates.max_postpone_days')) { 175 | throw InvalidPendingParametersException::aboveMaximumPostponeDays(); 176 | } 177 | 178 | return [$startAt, $revertAt]; 179 | } 180 | 181 | public function update(array $attributes = [], array $options = []) 182 | { 183 | $this->validatePendingAttributes($attributes); 184 | 185 | [$startAt, $revertAt] = $this->get(); 186 | 187 | $this->model->fill($attributes); 188 | 189 | $pendingAttributes = $this->model->getDirty(); 190 | 191 | if (! $startAt) { 192 | $pendingAttributes = array_intersect_key($this->model->getOriginal(), $pendingAttributes); 193 | $this->model->update($attributes, $options); 194 | } 195 | 196 | if (! $pendingAttributes) { 197 | return false; 198 | } 199 | 200 | $this->model->pendingUpdates()->create([ 201 | 'values' => $pendingAttributes, 202 | 'start_at' => $startAt, 203 | 'revert_at' => $revertAt, 204 | ]); 205 | 206 | return true; 207 | } 208 | 209 | protected function validatePendingAttributes($attributes) 210 | { 211 | $disallowedAttributes = array_diff( 212 | array_keys($attributes), 213 | $this->model->allowedPendingAttributes() 214 | ); 215 | 216 | if (! empty($disallowedAttributes)) { 217 | throw InvalidAttributeException::create(); 218 | } 219 | } 220 | } 221 | --------------------------------------------------------------------------------