├── .github └── workflows │ └── run-tests.yml ├── .gitignore ├── .styleci.yml ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src └── Draftable.php └── tests ├── DraftableTest.php ├── TestCase.php ├── TestModel.php └── migrations └── 2020_01_01_000000_create_test_models_table.php /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | php: [7.4, 7.3] 12 | laravel: [^8.0, ^7.0, ^6.0, ~5.8.0] 13 | dependency-versions: [prefer-lowest, prefer-stable] 14 | include: 15 | - laravel: ^8.0 16 | testbench: ^6.0 17 | - laravel: ^7.0 18 | testbench: ^5.0 19 | - laravel: ^6.0 20 | testbench: ^4.0 21 | - laravel: ~5.8.0 22 | testbench: ~3.8.0 23 | 24 | name: Laravel ${{ matrix.laravel }} | PHP ${{ matrix.php }} | ${{ matrix.dependency-versions }} 25 | 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v1 29 | 30 | - name: Cache dependencies 31 | uses: actions/cache@v1 32 | with: 33 | path: ~/.composer/cache/files 34 | key: dependencies-composer-${{ hashFiles('composer.json') }}-php-${{ matrix.php }}-laravel-${{ matrix.laravel }} 35 | 36 | - name: Setup PHP 37 | uses: shivammathur/setup-php@v2 38 | with: 39 | php-version: ${{ matrix.php }} 40 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, mysql, mysqli, pdo_mysql, bcmath, soap, intl, gd, exif, iconv, imagick 41 | coverage: none 42 | 43 | - name: Install dependencies 44 | run: | 45 | composer require "symfony/console:>=4.4" "illuminate/database:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 46 | composer update --${{ matrix.dependency-versions }} --prefer-dist --no-interaction --no-suggest 47 | 48 | - name: Execute tests 49 | run: vendor/bin/phpunit 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.vscode 3 | /vendor 4 | .DS_Store 5 | .phpunit.result.cache 6 | composer.lock 7 | phpunit.xml 8 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Optix Solutions 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 all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eloquent Draftable 2 | 3 | ![Packagist Version](https://img.shields.io/packagist/v/optix/eloquent-draftable) 4 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/optixsolutions/eloquent-draftable/Run%20tests) 5 | ![StyleCI](https://styleci.io/repos/133484703/shield) 6 | 7 | Add draftable functionality to your eloquent models. 8 | 9 | ## Installation 10 | 11 | You can install this package via composer. 12 | 13 | ```bash 14 | composer require optix/eloquent-draftable 15 | ``` 16 | 17 | ## Setup 18 | 19 | 1. Add a nullable timestamp `published_at` column to your model's database table. 20 | 21 | ```php 22 | $table->timestamp('published_at')->nullable(); 23 | ``` 24 | 25 | 2. Include the `Optix\Draftable\Draftable` trait in your model. 26 | 27 | ```php 28 | class Post extends Model 29 | { 30 | use Draftable; 31 | } 32 | ``` 33 | 34 | ## Usage 35 | 36 | **Query scopes** 37 | 38 | When the `Draftable` trait is included in a model, a global scope will be registered to automatically exclude 39 | draft records from query results. Therefore, in order to query draft records you must apply one of the local 40 | scopes outlined below. 41 | 42 | ```php 43 | // Only retrieve published records... 44 | $onlyPublished = Post::all(); 45 | 46 | // Retrieve draft & published records... 47 | $withDrafts = Post::withDrafts()->get(); 48 | 49 | // Only retrieve draft records... 50 | $onlyDrafts = Post::onlyDrafts()->get(); 51 | ``` 52 | 53 | **Publish a model** 54 | 55 | ```php 56 | $post = Post::withDrafts()->first(); 57 | 58 | // Publish without saving... 59 | $post->setPublished(true); 60 | 61 | // Publish and save... 62 | $post->publish(); // or $post->publish(true); 63 | ``` 64 | 65 | When you attempt to publish a model that's already been published, the `published_at` timestamp will not be updated. 66 | 67 | **Draft a model** 68 | 69 | ```php 70 | // Draft without saving... 71 | $post->setPublished(false); 72 | 73 | // Draft and save... 74 | $post->draft(); // or $post->publish(false); 75 | ``` 76 | 77 | **Schedule a model to be published** 78 | 79 | ```php 80 | $publishDate = Carbon::now()->addWeek(); 81 | // $publishDate = '2020-01-01 00:00:00'; 82 | // $publishDate = '+1 week'; 83 | 84 | // Schedule without saving... 85 | $post->setPublishedAt($publishDate); 86 | 87 | // Schedule and save... 88 | $post->publishAt($publishDate); 89 | ``` 90 | 91 | The methods outlined above both require a `$date` parameter of type `DateTimeInterface|string|null`. 92 | 93 | **Get the published status of a model** 94 | 95 | ```php 96 | // Determine if the model is published... 97 | $post->isPublished(); 98 | 99 | // Determine if the model is draft... 100 | $post->isDraft(); 101 | ``` 102 | 103 | ## License 104 | 105 | This package is licensed under the [MIT license](LICENSE.md). 106 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optix/eloquent-draftable", 3 | "description": "Add draftable functionality to your eloquent models.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Jack Robertson", 8 | "email": "jack@optixsolutions.co.uk" 9 | } 10 | ], 11 | "require": { 12 | "php": "^7.3", 13 | "illuminate/database": "~5.8.0|^6.0|^7.0|^8.0" 14 | }, 15 | "require-dev": { 16 | "orchestra/testbench": "~3.8.0|^4.0|^5.0|^6.0" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "Optix\\Draftable\\": "src/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "Optix\\Draftable\\Tests\\": "tests/" 26 | } 27 | }, 28 | "config": { 29 | "sort-packages": true 30 | }, 31 | "minimum-stability": "dev", 32 | "prefer-stable": true 33 | } 34 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests 14 | 15 | 16 | 17 | 18 | ./src 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Draftable.php: -------------------------------------------------------------------------------- 1 | whereNotNull('published_at') 25 | ->where('published_at', '<=', Carbon::now()); 26 | }); 27 | } 28 | 29 | /** 30 | * Include draft records in query results. 31 | * 32 | * @param Builder $query 33 | * @return void 34 | */ 35 | public function scopeWithDrafts(Builder $query) 36 | { 37 | $query->withoutGlobalScope('published'); 38 | } 39 | 40 | /** 41 | * Exclude published records from query results. 42 | * 43 | * @param Builder $query 44 | * @return void 45 | */ 46 | public function scopeOnlyDrafts(Builder $query) 47 | { 48 | $query->withDrafts()->where(function (Builder $query) { 49 | $query 50 | ->whereNull('published_at') 51 | ->orWhere('published_at', '>', Carbon::now()); 52 | }); 53 | } 54 | 55 | /** 56 | * Determine if the model is published. 57 | * 58 | * @return bool 59 | */ 60 | public function isPublished() 61 | { 62 | return ! is_null($this->published_at) 63 | && $this->published_at <= Carbon::now(); 64 | } 65 | 66 | /** 67 | * Determine if the model is draft. 68 | * 69 | * @return bool 70 | */ 71 | public function isDraft() 72 | { 73 | return ! $this->isPublished(); 74 | } 75 | 76 | /** 77 | * Set the value of the model's published at column. 78 | * 79 | * @param DateTimeInterface|string|null $date 80 | * @return $this 81 | */ 82 | public function setPublishedAt($date) 83 | { 84 | if (! is_null($date)) { 85 | $date = Carbon::parse($date); 86 | } 87 | 88 | $this->published_at = $date; 89 | 90 | return $this; 91 | } 92 | 93 | /** 94 | * Set the value of the model's published status. 95 | * 96 | * @param bool $published 97 | * @return $this 98 | */ 99 | public function setPublished($published) 100 | { 101 | if (! $published) { 102 | return $this->setPublishedAt(null); 103 | } 104 | 105 | if ($this->isDraft()) { 106 | return $this->setPublishedAt(Carbon::now()); 107 | } 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * Schedule the model to be published. 114 | * 115 | * @param DateTimeInterface|string|null $date 116 | * @return $this 117 | */ 118 | public function publishAt($date) 119 | { 120 | $this->setPublishedAt($date)->save(); 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Mark the model as published. 127 | * 128 | * @param bool $publish 129 | * @return $this 130 | */ 131 | public function publish($publish = true) 132 | { 133 | $this->setPublished($publish)->save(); 134 | 135 | return $this; 136 | } 137 | 138 | /** 139 | * Mark the model as draft. 140 | * 141 | * @return $this 142 | */ 143 | public function draft() 144 | { 145 | return $this->publish(false); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/DraftableTest.php: -------------------------------------------------------------------------------- 1 | newTestModel(); 16 | 17 | $this->assertFalse($draftModel->isPublished()); 18 | 19 | $publishedModel = $this->newTestModel(Carbon::now()); 20 | 21 | $this->assertTrue($publishedModel->isPublished()); 22 | 23 | $scheduledModel = $this->newTestModel( 24 | $scheduledFor = Carbon::now()->addDay() 25 | ); 26 | 27 | $this->assertFalse($scheduledModel->isPublished()); 28 | 29 | // Spoof now to be the date the model is scheduled for... 30 | Carbon::setTestNow($scheduledFor); 31 | 32 | $this->assertTrue($scheduledModel->isPublished()); 33 | } 34 | 35 | /** @test */ 36 | public function it_can_determine_if_a_model_is_draft() 37 | { 38 | $draftModel = $this->newTestModel(); 39 | 40 | $this->assertTrue($draftModel->isDraft()); 41 | 42 | $publishedModel = $this->newTestModel(Carbon::now()); 43 | 44 | $this->assertFalse($publishedModel->isDraft()); 45 | 46 | $scheduledModel = $this->newTestModel( 47 | $scheduledFor = Carbon::now()->addDay() 48 | ); 49 | 50 | $this->assertTrue($scheduledModel->isDraft()); 51 | 52 | // Spoof now to be the date the model is scheduled for... 53 | Carbon::setTestNow($scheduledFor); 54 | 55 | $this->assertFalse($scheduledModel->isDraft()); 56 | } 57 | 58 | /** @test */ 59 | public function it_will_exclude_draft_records_from_query_results_by_default() 60 | { 61 | // Create two draft models... 62 | $this->createTestModel(); 63 | $this->createTestModel(Carbon::now()->addDay()); 64 | 65 | // Create two published models... 66 | $this->createTestModel(Carbon::now()->subMinute()); 67 | $this->createTestModel(Carbon::now()); 68 | 69 | $models = TestModel::all(); 70 | 71 | $this->assertCount(2, $models); 72 | 73 | $models->each(function (TestModel $model) { 74 | $this->assertTrue($model->isPublished()); 75 | }); 76 | } 77 | 78 | /** @test */ 79 | public function it_can_include_draft_records_in_query_results() 80 | { 81 | // Create two draft models... 82 | $this->createTestModel(); 83 | $this->createTestModel(Carbon::now()->addDay()); 84 | 85 | // Create two published models... 86 | $this->createTestModel(Carbon::now()->subMinute()); 87 | $this->createTestModel(Carbon::now()); 88 | 89 | $models = TestModel::withDrafts()->get(); 90 | 91 | $draftCount = 0; 92 | $publishedCount = 0; 93 | 94 | foreach ($models as $model) { 95 | if ($model->isDraft()) { 96 | $draftCount++; 97 | } else { 98 | $publishedCount++; 99 | } 100 | } 101 | 102 | $this->assertEquals(2, $draftCount); 103 | $this->assertEquals(2, $publishedCount); 104 | } 105 | 106 | /** @test */ 107 | public function it_can_exclude_published_records_from_query_results() 108 | { 109 | // Create two draft models... 110 | $this->createTestModel(); 111 | $this->createTestModel(Carbon::now()->addDay()); 112 | 113 | // Create two published models... 114 | $this->createTestModel(Carbon::now()->subMinute()); 115 | $this->createTestModel(Carbon::now()); 116 | 117 | $models = TestModel::onlyDrafts()->get(); 118 | 119 | $this->assertCount(2, $models); 120 | 121 | $models->each(function (TestModel $model) { 122 | $this->assertTrue($model->isDraft()); 123 | }); 124 | } 125 | 126 | /** @test */ 127 | public function it_can_mark_a_model_as_published() 128 | { 129 | $model = $this->createTestModel(); 130 | 131 | $this->assertFalse($model->isPublished()); 132 | 133 | $model->publish(); 134 | 135 | // The model should now be published... 136 | $this->assertTrue($model->isPublished()); 137 | 138 | // Ensure the change was saved... 139 | $this->assertTrue($model->isClean()); 140 | } 141 | 142 | /** @test */ 143 | public function it_can_mark_a_model_as_published_without_saving() 144 | { 145 | $model = $this->newTestModel(); 146 | 147 | $this->assertFalse($model->isPublished()); 148 | 149 | $model->setPublished(true); 150 | 151 | // The model should now be published... 152 | $this->assertTrue($model->isPublished()); 153 | 154 | // Ensure the change was not saved... 155 | $this->assertTrue($model->isDirty()); 156 | } 157 | 158 | /** @test */ 159 | public function it_can_mark_a_model_as_draft() 160 | { 161 | $model = $this->createTestModel(Carbon::now()); 162 | 163 | $this->assertFalse($model->isDraft()); 164 | 165 | $model->draft(); 166 | 167 | // The model should now be draft... 168 | $this->assertTrue($model->isDraft()); 169 | 170 | // Ensure the change was saved... 171 | $this->assertTrue($model->isClean()); 172 | } 173 | 174 | /** @test */ 175 | public function it_can_mark_a_model_as_draft_without_saving() 176 | { 177 | $model = $this->newTestModel(Carbon::now()); 178 | 179 | $this->assertFalse($model->isDraft()); 180 | 181 | $model->setPublished(false); 182 | 183 | // The model should now be draft... 184 | $this->assertTrue($model->isDraft()); 185 | 186 | // Ensure the change was not saved... 187 | $this->assertTrue($model->isDirty()); 188 | } 189 | 190 | /** @test */ 191 | public function it_can_publish_or_draft_a_model_based_on_a_boolean_value() 192 | { 193 | $model = $this->newTestModel(); 194 | 195 | $this->assertTrue($model->isDraft()); 196 | 197 | // Publish without saving... 198 | $model->setPublished(true); 199 | 200 | $this->assertTrue($model->isPublished()); 201 | 202 | // Draft without saving... 203 | $model->setPublished(false); 204 | 205 | $this->assertTrue($model->isDraft()); 206 | 207 | // Publish and save... 208 | $model->publish(true); 209 | 210 | $this->assertTrue($model->isPublished()); 211 | 212 | // Draft and save... 213 | $model->publish(false); 214 | 215 | $this->assertTrue($model->isDraft()); 216 | } 217 | 218 | /** @test */ 219 | public function it_will_not_update_the_published_at_timestamp_when_publishing_an_already_published_model() 220 | { 221 | $publishedAt = Carbon::now()->startOfDay()->subDay(); 222 | 223 | $model = $this->newTestModel($publishedAt); 224 | 225 | $this->assertTrue($model->isPublished()); 226 | 227 | // Publish without saving... 228 | $model->setPublished(true); 229 | 230 | $this->assertEquals( 231 | $publishedAt->toDateTimeString(), 232 | $model->published_at->toDateTimeString() 233 | ); 234 | 235 | // Publish and save... 236 | $model->publish(true); 237 | 238 | $this->assertEquals( 239 | $publishedAt->toDateTimeString(), 240 | $model->published_at->toDateTimeString() 241 | ); 242 | } 243 | 244 | /** @test */ 245 | public function it_can_schedule_a_model_to_be_published() 246 | { 247 | $model = $this->createTestModel(Carbon::now()); 248 | 249 | $this->assertTrue($model->isPublished()); 250 | 251 | $publishDate = Carbon::now()->startOfDay()->addWeek(); 252 | 253 | $model->publishAt($publishDate); 254 | 255 | $this->assertFalse($model->isPublished()); 256 | 257 | $this->assertEquals( 258 | $publishDate->toDateTimeString(), 259 | $model->published_at->toDateTimeString() 260 | ); 261 | 262 | // Ensure the change was saved... 263 | $this->assertTrue($model->isClean()); 264 | 265 | Carbon::setTestNow($publishDate); 266 | 267 | $this->assertTrue($model->isPublished()); 268 | } 269 | 270 | /** @test */ 271 | public function it_can_schedule_a_model_to_be_published_without_saving() 272 | { 273 | $model = $this->createTestModel(Carbon::now()); 274 | 275 | $this->assertTrue($model->isPublished()); 276 | 277 | $publishDate = Carbon::now()->startOfDay()->addWeek(); 278 | 279 | $model->setPublishedAt($publishDate); 280 | 281 | $this->assertFalse($model->isPublished()); 282 | 283 | $this->assertEquals( 284 | $publishDate->toDateTimeString(), 285 | $model->published_at->toDateTimeString() 286 | ); 287 | 288 | // Ensure the change was not saved... 289 | $this->assertTrue($model->isDirty()); 290 | 291 | Carbon::setTestNow($publishDate); 292 | 293 | $this->assertTrue($model->isPublished()); 294 | } 295 | 296 | /** 297 | * @test 298 | * 299 | * @param Carbon $now 300 | * @param mixed $input 301 | * @param Carbon $expected 302 | * 303 | * @dataProvider acceptedDates 304 | */ 305 | public function it_can_accept_the_publish_date_in_multiple_formats($now, $input, $expected) 306 | { 307 | $model = $this->createTestModel(); 308 | 309 | Carbon::setTestNow($now); 310 | 311 | $model->setPublishedAt($input); 312 | 313 | $this->assertEquals( 314 | $expected->toDateTimeString(), 315 | $model->published_at->toDateTimeString() 316 | ); 317 | 318 | // Ensure the change was not saved... 319 | $this->assertTrue($model->isDirty()); 320 | 321 | $model->setPublished(false); 322 | 323 | $model->publishAt($input); 324 | 325 | $this->assertEquals( 326 | $expected->toDateTimeString(), 327 | $model->published_at->toDateTimeString() 328 | ); 329 | 330 | // Ensure the change was saved... 331 | $this->assertFalse($model->isDirty()); 332 | } 333 | 334 | public function acceptedDates() 335 | { 336 | $now = Carbon::now(); 337 | 338 | return [ 339 | [$now, $now, $now], 340 | [$now, $now->copy()->addDay()->toDateTimeString(), $now->copy()->addDay()], 341 | [$now, 'now', $now], 342 | [$now, '+1 week', $now->copy()->addWeek()], 343 | ]; 344 | } 345 | 346 | /** @test */ 347 | public function it_can_accept_a_null_publish_date_to_indefinitely_draft_a_model() 348 | { 349 | $model = $this->createTestModel(Carbon::now()); 350 | 351 | $model->setPublishedAt(null); 352 | 353 | $this->assertTrue($model->isDraft()); 354 | 355 | // Ensure the change was not saved... 356 | $this->assertTrue($model->isDirty()); 357 | 358 | $model->setPublished(true); 359 | 360 | $model->publishAt(null); 361 | 362 | $this->assertTrue($model->isDraft()); 363 | 364 | // Ensure the change was saved... 365 | $this->assertTrue($model->isClean()); 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__.'/migrations'); 15 | } 16 | 17 | protected function getEnvironmentSetUp($app) 18 | { 19 | $app['config']->set('database.default', 'sqlite'); 20 | $app['config']->set('database.connections.sqlite', [ 21 | 'driver' => 'sqlite', 22 | 'database' => ':memory:', 23 | 'prefix' => '', 24 | ]); 25 | } 26 | 27 | /** 28 | * @param DateTimeInterface|string|null $publishedAt 29 | * @return TestModel 30 | */ 31 | protected function newTestModel($publishedAt = null) 32 | { 33 | return new TestModel([ 34 | 'published_at' => $publishedAt, 35 | ]); 36 | } 37 | 38 | /** 39 | * @param DateTimeInterface|string|null $publishedAt 40 | * @return TestModel 41 | */ 42 | protected function createTestModel($publishedAt = null) 43 | { 44 | return tap( 45 | $this->newTestModel($publishedAt), 46 | function (TestModel $model) { 47 | $model->save(); 48 | } 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/TestModel.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 18 | $table->timestamp('published_at')->nullable(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::dropIfExists('test_models'); 30 | } 31 | } 32 | --------------------------------------------------------------------------------