├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── php.yml ├── LICENSE.md ├── README.md ├── composer.json ├── config └── laraconfig.php ├── database └── migrations │ ├── 00_00_00_000000_create_user_settings_metadata_table.php │ └── 00_00_00_000000_create_user_settings_table.php ├── phpunit.xml ├── src ├── Console │ └── Commands │ │ ├── CleanCommand.php │ │ ├── MigrateCommand.php │ │ └── PublishCommand.php ├── Eloquent │ ├── Casts │ │ └── DynamicCasting.php │ ├── Metadata.php │ ├── Scopes │ │ ├── AddMetadata.php │ │ ├── FilterBags.php │ │ └── WhereConfig.php │ └── Setting.php ├── Facades │ └── Setting.php ├── HasConfig.php ├── LaraconfigServiceProvider.php ├── Migrator │ ├── Data.php │ ├── Migrator.php │ └── Pipes │ │ ├── ConfirmSettingsRefresh.php │ │ ├── ConfirmSettingsToDelete.php │ │ ├── CreateNewMetadata.php │ │ ├── EnsureFromTargetsExist.php │ │ ├── EnsureSomethingToMigrate.php │ │ ├── FindModelsWithSettings.php │ │ ├── FlushCache.php │ │ ├── InvalidateCache.php │ │ ├── LoadDeclarations.php │ │ ├── LoadMetadata.php │ │ ├── RemoveOldMetadata.php │ │ └── UpdateExistingMetadata.php ├── MorphManySettings.php ├── Registrar │ ├── Declaration.php │ └── SettingRegistrar.php ├── SettingsCache.php └── SettingsCollection.php └── stubs └── users.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Help me support this package 2 | 3 | ko_fi: DarkGhostHunter 4 | custom: ['https://paypal.me/darkghosthunter'] -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | php: [8.0] 15 | laravel: [^8.43] 16 | dependency-version: [prefer-lowest, prefer-stable] 17 | include: 18 | - laravel: ^8.43 19 | testbench: ^6.19 20 | 21 | name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.dependency-version }} 22 | 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: mbstring, intl 32 | coverage: xdebug 33 | 34 | - name: Cache dependencies 35 | uses: actions/cache@v2 36 | with: 37 | path: ~/.composer/cache/files 38 | key: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 39 | restore-keys: ${{ runner.os }}-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer- 40 | 41 | - name: Install dependencies 42 | run: | 43 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-progress --no-update 44 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-progress --no-suggest 45 | 46 | - name: Run Tests 47 | run: composer run-script test 48 | 49 | - name: Upload Coverage to Coveralls 50 | env: 51 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | COVERALLS_SERVICE_NAME: github 53 | run: | 54 | rm -rf composer.* vendor/ 55 | composer require php-coveralls/php-coveralls 56 | vendor/bin/php-coveralls 57 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Italo Israel Baeza Cabrera 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 | This package has been archived. 2 | 3 | Sorry guys and gals, I bit more than I can chew and I'm currently not using this package to justify its support. 4 | 5 | I may revisit this in the near future. 6 | 7 | --- 8 | 9 | ![Xavier von Erlach - Unsplash #ooR1jY2yFr4](https://images.unsplash.com/photo-1570221622224-3bb8f08f166c?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1200&h=400&q=80) 10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/darkghosthunter/laraconfig.svg)](https://packagist.org/packages/darkghosthunter/laraconfig) [![License](https://poser.pugx.org/darkghosthunter/laraconfig/license)](https://packagist.org/packages/darkghosthunter/laraconfig) ![](https://img.shields.io/packagist/php-v/darkghosthunter/laraconfig.svg) ![](https://github.com/DarkGhostHunter/Laraconfig/workflows/PHP%20Composer/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/DarkGhostHunter/Laraconfig/badge.svg?branch=master)](https://coveralls.io/github/DarkGhostHunter/Laraconfig?branch=master) [![Laravel Octane Compatible](https://img.shields.io/badge/Laravel%20Octane-Compatible-success?style=flat&logo=laravel)](https://github.com/laravel/octane) 12 | 13 | # Laraconfig 14 | 15 | Per-user settings repository system for Laravel. 16 | 17 | This package allows users to have settings that can be queried, changed and even updated, effortlessly and quickly. 18 | 19 | ```php 20 | User::find(1)->settings->set('color', 'red'); 21 | ``` 22 | 23 | ## Requirements 24 | 25 | - Laravel 8.x 26 | - PHP 8.0 or later 27 | 28 | ## How it works 29 | 30 | Laraconfig works extending Laravel relations, and includes a migration system to easily manage them. 31 | 32 | Each Setting is just a value, and references a parent "metadata" that contains the information like the type and name, while being linked to a user. 33 | 34 | Since Laraconfig uses the Eloquent ORM behind the scenes, getting a one or all settings is totally transparent to the developer. 35 | 36 | ## Quickstart 37 | 38 | You can install the package via composer. 39 | 40 | composer require darkghosthunter/laraconfig 41 | 42 | First, publish and run the migrations. These will add two tables called `user_settings` and `user_settings_metadata`. One holds the values per user, the other the metadata of the setting, respectively. 43 | 44 | php artisan vendor:publish --provider="DarkGhostHunter\Laraconfig\LaraconfigServiceProvider" --tag="migrations" 45 | php artisan migrate 46 | 47 | > The migration uses a morph column to connect to the User. You can change it before migrating. 48 | 49 | Second, add the `HasConfig` trait to the User models you want to have settings. 50 | 51 | ```php 52 | namespace App\Models; 53 | 54 | use Illuminate\Foundation\Auth\User as Authenticatable; 55 | use DarkGhostHunter\Laraconfig\HasConfig; 56 | 57 | class User extends Authenticatable 58 | { 59 | use HasConfig; 60 | 61 | // ... 62 | } 63 | ``` 64 | 65 | Finally, use the `settings:publish` artisan command. This will create a `settings` folder in the root of your project and a `users.php` file. 66 | 67 | php artisan settings:publish 68 | 69 | Now, let's create some settings. 70 | 71 | ## Settings Manifest 72 | 73 | Laraconfig makes managing user settings globally using a _manifest_ of sorts, the `settings/users.php` file. You will see a sample setting already written. 74 | 75 | ```php 76 | use DarkGhostHunter\Laraconfig\Facades\Setting; 77 | 78 | Setting::name('color')->string(); 79 | ``` 80 | 81 | ### Creating a setting 82 | 83 | To create a setting, use the `Setting` facade. You can start with setting the name, which must be unique, and then declare the type. 84 | 85 | ```php 86 | use DarkGhostHunter\Laraconfig\Facades\Setting; 87 | 88 | Setting::name('dark_mode')->boolean(); 89 | ``` 90 | 91 | Laraconfig is compatible with 7 types of settings, mirroring their PHP native types, along the Collection and Datetime (Carbon) objects. 92 | 93 | * `array()` 94 | * `boolean()` 95 | * `collection()` 96 | * `datetime()` 97 | * `float()` 98 | * `integer()` 99 | * `string()` 100 | 101 | > Arrays and Collections are serialized in the database as JSON. 102 | 103 | ### Default value 104 | 105 | All settings have a default value of `null`, but you can use the `default()` method to set a different initial value. 106 | 107 | ```php 108 | use DarkGhostHunter\Laraconfig\Facades\Setting; 109 | 110 | Setting::name('color')->string()->default('black'); 111 | ``` 112 | 113 | > You can later revert the value back to the default using [`setDefault()`](#defaulting-a-setting). 114 | 115 | ### Enabled or Disabled 116 | 117 | By default, all settings are [enabled by default](#disablingenabling-settings), but you can change this using `disabled()`. 118 | 119 | ```php 120 | Setting::name('color')->disabled(); 121 | ``` 122 | 123 | > Enabled or disable is presentational; a disabled setting can still be updated. You can programmatically set a value using [`setIfEnabled()`](#disablingenabling-settings). 124 | 125 | ### Group settings 126 | 127 | You can set a group name to a setting. This can be handy when you want to display settings in the frontend in an ordered manner by [separating them in groups](https://laravel.com/docs/collections#method-groupby). 128 | 129 | ```php 130 | Setting::name('color')->group('theme'); 131 | ``` 132 | 133 | ### Bag 134 | 135 | When Laraconfig migrates the new settings, these are created to all models. You can filter a given set of settings through "bags". 136 | 137 | By default, all settings are created under the `users` bag, but you can change the default bag for anything using the `bag()` method. 138 | 139 | ```php 140 | Setting::name('color')->group('theme')->bag('style'); 141 | 142 | Setting::name('notify_email')->boolean()->default(true)->bag('notifications'); 143 | Setting::name('notify_sms')->boolean()->default(false)->bag('notifications'); 144 | ``` 145 | 146 | Later, in your model, you can filter the bags you want to work with using [`filterBags()`](#setting-bags) in your model. 147 | 148 | ## Migrating settings 149 | 150 | Once you're done creating your settings, you should use `settings:migrate` to let Laraconfig add the settings metadata to your database. 151 | 152 | php artisan settings:migrate 153 | 154 | Behind the scenes, Laraconfig will look into your Models for those using the `HasConfig` trait, and populate the settings accordingly using the information on the manifest. 155 | 156 | > Migration run only _forward_. There is no way to revert a migration once done. On production, removing settings needs confirmation. 157 | 158 | ### Adding new settings 159 | 160 | Simply create a new setting and run `settings:migrate`. Existing settings won't be created again, as Laraconfig will check their existence before doing it. 161 | 162 | ```php 163 | use DarkGhostHunter\Laraconfig\Facades\Setting; 164 | 165 | Setting::name('color')->string()->default('black'); 166 | 167 | // This new setting will be created 168 | Setting::name('notifications')->boolean()->default(true); 169 | ``` 170 | 171 | ### Removing old settings 172 | 173 | To remove old settings, simply remove their declaration and run `settings:migrate`. Laraconfig compares the settings declared to the ones created in the database, and removes those that no longer exist in the manifest at the end of the migration execution. 174 | 175 | ```php 176 | use DarkGhostHunter\Laraconfig\Facades\Setting; 177 | 178 | // Commenting this line will remove the "color" setting on migration. 179 | // Setting::name('color')->string()->default('black'); 180 | 181 | // This new setting will be created 182 | Setting::name('notifications')->boolean()->default(true); 183 | ``` 184 | 185 | > Since this procedure can be dangerous, **confirmation** will be needed on production environments. 186 | 187 | ### Upgrading settings 188 | 189 | You don't need to get directly into the database to update a setting. Instead, just change the setting properties directly in the manifest. Laraconfig will update the metadata accordingly. 190 | 191 | Let's say we have a "color" setting we wish to update from a string to an array of colors, with a default and a group. 192 | 193 | ```php 194 | Setting::name('color')->string()->bag('theme'); 195 | 196 | // This is the new declaration. 197 | // Setting::name('color') 198 | // ->array() 199 | // ->default(['black']) 200 | // ->group('theme'); 201 | ``` 202 | 203 | Laraconfig will detect the new changes, and update the metadata keeping the users value intact. 204 | 205 | ```php 206 | // This is the old declaration. 207 | // Setting::name('color')->string()->bag('theme'); 208 | 209 | Setting::name('color') 210 | ->array() 211 | ->default(['black']) 212 | ->group('theme'); 213 | ``` 214 | 215 | > Updating only occurs if the setting is different from before at migration time. 216 | 217 | Once done, we can migrate the old setting to the new one using `settings:migrate`. Users will keep the same setting value they had, but... What if we want to also change the value for each user? We can use the `using()` method to feed each user setting to a callback that will return the new value. 218 | 219 | ```php 220 | Setting::name('color') 221 | ->array() 222 | ->default('black') 223 | ->group('theme') 224 | ->using(fn ($old) => $old->value ?? 'black'); // If the value is null, set it as "black". 225 | ``` 226 | 227 | > The `using()` method only runs if the setting is different from before at migration time. 228 | 229 | Behind the scenes, Laraconfig will look for the "color" setting, update the metadata, and then use a [`lazy()` query](https://laravel.com/docs/queries#streaming-results-lazily) to update the value with the callback. 230 | 231 | > Consider migrating directly on the database if you have hundreds of thousands of records, as this procedure is safer but slower than a direct SQL statement. 232 | 233 | ### Migrating to a new setting 234 | 235 | On other occasions, you may want to migrate a setting to a completely new one. In both cases you can use `from()` to get the old setting value to migrate from, and `using()` if you want to also update the value of each user. 236 | 237 | Taking the same example above, we will migrate the "color" setting to a simple "dark theme" setting. 238 | 239 | ```php 240 | // This old declaration will be deleted after the migration ends. 241 | // Setting::name('color')->string()->bag('theme'); 242 | 243 | // This is a new setting. 244 | Setting::name('dark') 245 | ->boolean() 246 | ->default(false) 247 | ->group('theme') 248 | ->from('color') 249 | ->using(static fn ($old) => $old->value === 'black'); // If it's black, then it's dark. 250 | ``` 251 | 252 | > The `from` and `using` are executed only if the old setting exists at migration time. 253 | 254 | Behind the scenes, Laraconfig creates the new "theme" setting first, and then looks for the old "color" setting in the database to translate the old values to the new ones. Since the old setting is not present in the manifest, it will be deleted from the database. 255 | 256 | ## Managing Settings 257 | 258 | Laraconfig handles settings like any [Eloquent Morph-Many Relationship](https://laravel.com/docs/eloquent-relationships#one-to-many-polymorphic-relations), but supercharged. 259 | 260 | Just simply use the `settings` property on your model. This property is like your normal [Eloquent Collection](https://laravel.com/docs/eloquent-collections), so you have access to all its tools. 261 | 262 | ```php 263 | $user = User::find(1); 264 | 265 | echo "Your color is: {$user->settings->get('color')}."; 266 | ``` 267 | 268 | > Using `settings` is preferred, as it will load the settings only once. 269 | 270 | ### Initializing 271 | 272 | By default, the `HasConfig` trait will create a new bag of Settings in the database after a User is successfully created through the Eloquent ORM, so you don't have to create any setting. 273 | 274 | In case you want to handle initialization manually, you can use the `shouldInitializeConfig()` method and return `false`, which can be useful when programmatically initializing the settings. 275 | 276 | ```php 277 | // app/Models/User.php 278 | 279 | /** 280 | * Check if the user should initialize settings automatically after creation. 281 | * 282 | * @return bool 283 | */ 284 | protected function shouldInitializeConfig(): bool 285 | { 286 | // Don't initialize the settings if the user is not verified from the start. 287 | // We will initialize them only once the email is properly verified. 288 | return null !== $this->email_verified_at; 289 | } 290 | ``` 291 | 292 | Since the user in the example above won't be initialized, we have to do it manually using `initialize()`. 293 | 294 | ```php 295 | // Initialize if not initialized before. 296 | $user->settings()->initialize(); 297 | 298 | // Forcefully initialize, even if already initialized. 299 | $user->settings()->initialize(true); 300 | ``` 301 | 302 | #### Checking settings initialization 303 | 304 | You can check if a user configuration has been initialized or not using `isInitialized()`. 305 | 306 | ```php 307 | if ($user->settings()->isInitialized()) { 308 | return 'You have a config!'; 309 | } 310 | ``` 311 | 312 | ### Retrieving settings 313 | 314 | You can easily get a value of a setting using the name, which makes everything into a single beautiful _oneliner_. 315 | 316 | ```php 317 | return "Your favorite color is {$user->settings->color}"; 318 | ``` 319 | 320 | Since this only supports alphanumeric and underscore characters, you can use `value()`. 321 | 322 | ```php 323 | return "Your favorite color is {$user->settings->value('color')}"; 324 | ``` 325 | 326 | You can also get the underlying Setting model using `get()`. If the setting doesn't exist, it will return `null`. 327 | 328 | ```php 329 | $setting = $user->settings->get('theme'); 330 | 331 | echo "You're using the [$setting->value] theme."; 332 | ``` 333 | 334 | Since the `settings` is a [collection](https://laravel.com/docs/eloquent-collections), you have access to all the goodies, like iteration: 335 | 336 | ```php 337 | foreach ($user->settings as $setting) { 338 | echo "The [$setting->name] has the [$setting->value] value."; 339 | } 340 | ``` 341 | 342 | You can also use the `only()` method to return a collection of settings by their name, or `except()` to retrieve all the settings except those issued. 343 | 344 | ```php 345 | $user->settings->only('colors', 'is_dark'); 346 | 347 | $user->settings->except('dark_mode'); 348 | ``` 349 | 350 | #### Grouping settings 351 | 352 | Since the list of settings is a collection, you can use `groups()` method to group them by the name of the group they belong. 353 | 354 | ```php 355 | $user->settings->groups(); // or ->groupBy('group') 356 | ``` 357 | 358 | > Note that Settings are grouped into the `default` group by default (no pun intended). 359 | 360 | ### Setting a value 361 | 362 | Setting a value can be easily done by issuing the name of the setting and the value. 363 | 364 | ```php 365 | $user->settings->color = 'red'; 366 | ``` 367 | 368 | Since this only supports settings with names made of alphanumeric and underscores, you can also set a value using the `set()` method by issuing the name of the setting. 369 | 370 | ```php 371 | $user->settings->set('color-default', 'red'); 372 | ``` 373 | 374 | Or, you can go the purist mode directly in the model itself. 375 | 376 | ```php 377 | $setting = $user->settings->get('color'); 378 | 379 | $setting->value = 'red'; 380 | $setting->save(); 381 | ``` 382 | 383 | You can also set multiple settings using an array when using `set()` in one go, which is useful when dealing with the [array returned by a validation](https://laravel.com/docs/validation#quick-writing-the-validation-logic). 384 | 385 | ```php 386 | $user->settings->set([ 387 | 'color' => 'red', 388 | 'dark_mode' => false, 389 | ]); 390 | ``` 391 | 392 | When [using the cache](#cache), any change invalidates the cache immediately and queues up a regeneration before the collection is garbage collected. 393 | 394 | > That being said, updating the settings directly into the database **doesn't regenerate the cache**. 395 | 396 | ### Defaulting a Setting 397 | 398 | You can turn the setting back to the default value using `setDefault()` on both the setting instance or using the `settings` property. 399 | 400 | ```php 401 | $setting = $user->settings->get('color'); 402 | 403 | $setting->setDefault(); 404 | 405 | $user->settings->setDefault('color'); 406 | ``` 407 | 408 | > If the setting has no default value, `null` will be used. 409 | 410 | ### Check if null 411 | 412 | Check if a `null` value is set using `isNull()` with the name of the setting. 413 | 414 | ```php 415 | if ($user->settings->isNull('color')) { 416 | return 'The color setting is not set.'; 417 | } 418 | ``` 419 | 420 | ### Disabling/Enabling settings 421 | 422 | For presentational purposes, all settings are enabled by default. You can enable or disable settings with the `enable()` and `disable()`, respectively. To check if the setting is enabled, use the `isEnabled()` method. 423 | 424 | ```php 425 | $user->settings->enable('color'); 426 | 427 | $user->settings->disable('color'); 428 | ``` 429 | 430 | > A disabled setting can be still set. If you want to set a value only if it's enabled, use `setIfEnabled()`. 431 | > 432 | > ```php 433 | > $user->settings->setIfEnabled('color', 'red'); 434 | > ``` 435 | 436 | ## Setting Bags 437 | 438 | Laraconfig uses one single bag called `default`. If you have declared in the manifest [different sets of bags](#bag), you can make a model to use only a particular set of bags with the `filterBags()` method, that should return the bag name (or names). 439 | 440 | ```php 441 | // app/Models/User.php 442 | i 443 | ``` 444 | 445 | The above will apply a filter to the query when retrieving settings from the database. This makes easy to swap bags when a user has a different role or property, or programmatically. 446 | 447 | > **All** settings are created for all models with `HasConfig` trait, regardless of the bags used by the model. 448 | 449 | #### Disabling the bag filter scope 450 | 451 | Laraconfig applies a query filter to exclude the settings not in the model bag. While this eases the development, sometimes you will want to work with the full set of settings available. 452 | 453 | There are two ways to disable the bag filter. The first one is relatively easy: simply use the `withoutGlobalScope()` at query time, which will allow to query all the settings available to the user. 454 | 455 | ```php 456 | use DarkGhostHunter\Laraconfig\Eloquent\Scopes\FilterBags; 457 | 458 | $allSettings = $user->settings()->withoutGlobalScope(FilterBags::class)->get(); 459 | ``` 460 | 461 | If you want a more _permanent_ solution, just simply return an empty array or `null` when using the `filterBags()` method in you model, which will disable the scope completely. 462 | 463 | ```php 464 | /** 465 | * Returns the bags this model uses for settings. 466 | * 467 | * @return array|string 468 | */ 469 | public function filterBags(): array|string|null 470 | { 471 | return null; 472 | } 473 | ``` 474 | 475 | ## Cache 476 | 477 | Hitting the database each request to retrieve the user settings can be detrimental if you expect to happen a lot. To avoid this, you can activate a cache which will be regenerated each time a setting changes. 478 | 479 | The cache implementation avoids data-races. It will regenerate the cache only for the last data changed, so if two or more processes try to save something into the cache, only the fresher data will be persisted. 480 | 481 | ### Enabling the cache 482 | 483 | You can easily enable the cache using the `LARACONFIG_CACHE` environment variable set to `true`, and use a non-default cache store (like Redis) with `LARACONFIG_STORE`. 484 | 485 | ```dotenv 486 | LARACONFIG_CACHE=true 487 | LARACONFIG_STORE=redis 488 | ``` 489 | 490 | > Alternatively, check the `laraconfig.php` file to customize the cache TTL and prefix. 491 | 492 | #### Managing the cache 493 | 494 | You can forcefully regenerate the cache of a single user using `regenerate()`. This basically saves the settings present and saves them into the cache. 495 | 496 | ```php 497 | $user->settings->regenerate(); 498 | ``` 499 | 500 | You can also invalidate the cached settings using `invalidate()`, which just deletes the entry from the cache. 501 | 502 | ```php 503 | $user->settings->invalidate(); 504 | ``` 505 | 506 | Finally, you can have a little peace of mind by setting `regeneratesOnExit` to `true`, which will regenerate the cache when the settings are garbage collected by the PHP process. 507 | 508 | ```php 509 | $user->settings->regeneratesOnExit = true; 510 | ``` 511 | 512 | > You can disable automatic regeneration on the config file. 513 | 514 | #### Regenerating the Cache on migration 515 | 516 | If the [Cache is activated](#cache), the migration will invalidate the setting cache for each user after it completes. 517 | 518 | Depending on the Cache system, forgetting each cache key can be detrimental. Instead, you can use the `--flush-cache` command to flush the cache store used by Laraconfig, instead of deleting each key one by one. 519 | 520 | php artisan settings:migrate --flush-cache 521 | 522 | > Since this will delete all the data of the cache, is recommended to use an exclusive cache store for Laraconfig, like a separate Redis database. 523 | 524 | ## Validation 525 | 526 | Settings values are _casted_, but not validated. You should validate in your app every value that you plan to store in a setting. 527 | 528 | ```php 529 | use App\Models\User; 530 | use Illuminate\Http\Request; 531 | 532 | public function store(Request $request, User $user) 533 | { 534 | $settings = $request->validate([ 535 | 'age' => 'required|numeric|min:14|max:100', 536 | 'color' => 'required|string|in:red,green,blue' 537 | ]); 538 | 539 | $user->settings->setIfEnabled($settings); 540 | 541 | // ... 542 | } 543 | ``` 544 | 545 | ## Testing 546 | 547 | Eventually you will land into the problem of creating settings and metadata for each user created. You can easily create Metadata directly into the database _before_ creating a user, unless you have disabled [initialization](#initializing). 548 | 549 | ```php 550 | public function test_user_has_settings(): void 551 | { 552 | Metadata::forceCreate([ 553 | 'name' => 'foo', 554 | 'type' => 'string', 555 | 'default' => 'bar', 556 | 'bag' => 'users', 557 | 'group' => 'default', 558 | ]); 559 | 560 | $user = User::create([ 561 | // ... 562 | ]); 563 | 564 | // ... 565 | } 566 | ``` 567 | 568 | ## Security 569 | 570 | If you discover any security related issues, please email darkghosthunter@gmail.com instead of using the issue tracker. 571 | 572 | ## License 573 | 574 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 575 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "darkghosthunter/laraconfig", 3 | "description": "Per-user settings repository system for Laravel", 4 | "minimum-stability": "dev", 5 | "prefer-stable": true, 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Italo Baeza C.", 10 | "email": "darkghosthunter@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": ">=8.0", 15 | "illuminate/database": "^8.43", 16 | "illuminate/support": "^8.43", 17 | "illuminate/collections": "^8.43", 18 | "illuminate/config": "^8.43", 19 | "illuminate/cache": "^8.43", 20 | "symfony/console": "^5.3" 21 | }, 22 | "require-dev": { 23 | "mockery/mockery": "^1.4.3", 24 | "orchestra/testbench": "^6.19", 25 | "phpunit/phpunit": "^9.5.4" 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Tests\\": "tests" 30 | } 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "DarkGhostHunter\\Laraconfig\\": "src" 35 | } 36 | }, 37 | "extra": { 38 | "laravel": { 39 | "providers": [ 40 | "DarkGhostHunter\\Laraconfig\\LaraconfigServiceProvider" 41 | ], 42 | "aliases": { 43 | "Setting": "DarkGhostHunter\\Laraconfig\\Facades\\Setting" 44 | } 45 | } 46 | }, 47 | "scripts": { 48 | "test": "vendor/bin/phpunit --coverage-clover build/logs/clover.xml", 49 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 50 | }, 51 | "config": { 52 | "sort-packages": true 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /config/laraconfig.php: -------------------------------------------------------------------------------- 1 | 'users', 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Cache 21 | |-------------------------------------------------------------------------- 22 | | 23 | | For some apps, retrieving and updating the list of settings can be slow. 24 | | To avoid this, you can use a cache to store the settings. Laraset will 25 | | invalidate the settings cache of the user when a change is detected. 26 | | 27 | */ 28 | 29 | 'cache' => [ 30 | 'enable' => env('LARACONFIG_CACHE', false), 31 | 'store' => env('LARACONFIG_STORE'), 32 | 'duration' => 60 * 60 * 3, // Store the settings for 3 hours 33 | 'prefix' => 'laraconfig', 34 | 'automatic' => true, // Regenerate the cache before garbage collection. 35 | ] 36 | ]; -------------------------------------------------------------------------------- /database/migrations/00_00_00_000000_create_user_settings_metadata_table.php: -------------------------------------------------------------------------------- 1 | id(); 19 | 20 | $table->string('name')->unique(); 21 | $table->string('type'); 22 | $table->string('default')->nullable(); 23 | $table->boolean('is_enabled')->default(true); 24 | 25 | $table->string('group')->default('default'); 26 | $table->string('bag')->default(config('laraset.default', 'users')); 27 | 28 | $table->timestamps(); 29 | }); 30 | } 31 | 32 | /** 33 | * Reverse the migrations. 34 | * 35 | * @return void 36 | */ 37 | public function down(): void 38 | { 39 | Schema::dropIfExists('user_settings_metadata'); 40 | } 41 | } -------------------------------------------------------------------------------- /database/migrations/00_00_00_000000_create_user_settings_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | 19 | $table->unsignedBigInteger('metadata_id'); 20 | 21 | // You can change this morph column to suit your needs, like using `uuidMorphs()`. 22 | // $table->uuidMorphs('settable'); 23 | $table->numericMorphs('settable'); 24 | 25 | $table->string('value')->nullable(); 26 | $table->boolean('is_enabled')->default(true); 27 | 28 | $table->timestamps(); 29 | }); 30 | } 31 | 32 | /** 33 | * Reverse the migrations. 34 | * 35 | * @return void 36 | */ 37 | public function down(): void 38 | { 39 | Schema::dropIfExists('user_settings'); 40 | } 41 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | tests 17 | tests/Dummies 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/Console/Commands/CleanCommand.php: -------------------------------------------------------------------------------- 1 | info("Deleted {$this->deleteOrphanedSettings()} orphaned settings."); 35 | 36 | return 0; 37 | } 38 | 39 | /** 40 | * Deletes orphaned settings. 41 | * 42 | * @return int 43 | */ 44 | protected function deleteOrphanedSettings(): int 45 | { 46 | return Setting::query() 47 | ->withoutGlobalScopes() 48 | ->doesntHave('user') 49 | ->orDoesntHave('metadata') 50 | ->delete(); 51 | } 52 | } -------------------------------------------------------------------------------- /src/Console/Commands/MigrateCommand.php: -------------------------------------------------------------------------------- 1 | getLaravel()->instance(InputInterface::class, $this->input); 54 | $this->getLaravel()->instance(OutputStyle::class, $this->output); 55 | 56 | try { 57 | $this->migrator->send($this->data)->thenReturn(); 58 | } catch (RuntimeException $exception) { 59 | $this->error($exception->getMessage()); 60 | return 1; 61 | } 62 | 63 | return 0; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Console/Commands/PublishCommand.php: -------------------------------------------------------------------------------- 1 | getLaravel()->basePath('settings/users.php'); 47 | 48 | // Add the manifest if it doesn't exists, or if the user confirms the replace. 49 | if ($this->filesystem->missing($path) || $this->confirm('A manifest file already exists. Overwrite?')) { 50 | $this->filesystem->ensureDirectoryExists($this->laravel->basePath('settings')); 51 | $this->filesystem->copy(static::STUB_PATH, $path); 52 | 53 | $this->info("Manifest published. Check it at: $path"); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Eloquent/Casts/DynamicCasting.php: -------------------------------------------------------------------------------- 1 | value('type')) { 44 | Metadata::TYPE_ARRAY => Arr::wrap(json_decode($value, true, 512, JSON_THROW_ON_ERROR)), 45 | Metadata::TYPE_BOOLEAN => (bool) $value, 46 | Metadata::TYPE_DATETIME => Carbon::parse($value), 47 | Metadata::TYPE_COLLECTION => new Collection(Arr::wrap(json_decode($value, true, 512, JSON_THROW_ON_ERROR))), 48 | Metadata::TYPE_FLOAT => (float) $value, 49 | Metadata::TYPE_INTEGER => (int) $value, 50 | default => $value, 51 | }; 52 | } 53 | 54 | /** 55 | * Transform the attribute to its underlying model values. 56 | * 57 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Setting|\DarkGhostHunter\Laraconfig\Eloquent\Metadata $model 58 | * @param string $key 59 | * @param mixed $value 60 | * @param array $attributes 61 | * 62 | * @return null|array|int|bool|float|string|\Illuminate\Support\Collection|\DateTimeInterface 63 | * @throws \JsonException 64 | */ 65 | public function set( 66 | $model, 67 | string $key, 68 | $value, 69 | array $attributes 70 | ): null|array|int|bool|float|string|Collection|DateTimeInterface { 71 | if (null === $value) { 72 | return null; 73 | } 74 | 75 | if ($model instanceof Setting && !isset($attributes['type'], $attributes['metadata_id'])) { 76 | return $value; 77 | } 78 | 79 | return match ($attributes['type'] ??= Metadata::whereKey($attributes['metadata_id'])->value('type')) { 80 | Metadata::TYPE_COLLECTION, 81 | Metadata::TYPE_ARRAY => json_encode(is_array($value) ? Arr::wrap($value) : $value, JSON_THROW_ON_ERROR), 82 | Metadata::TYPE_BOOLEAN => (bool) $value, 83 | Metadata::TYPE_DATETIME => Carbon::parse($value), 84 | Metadata::TYPE_STRING => (string) $value, 85 | Metadata::TYPE_INTEGER => (int) $value, 86 | Metadata::TYPE_FLOAT => (float) $value, 87 | default => $value, 88 | }; 89 | } 90 | } -------------------------------------------------------------------------------- /src/Eloquent/Metadata.php: -------------------------------------------------------------------------------- 1 | Casts\DynamicCasting::class, 50 | 'is_enabled' => 'boolean' 51 | ]; 52 | 53 | /** 54 | * The settings this metadata has. 55 | * 56 | * @return \Illuminate\Database\Eloquent\Relations\HasMany|\DarkGhostHunter\Laraconfig\Eloquent\Setting 57 | */ 58 | public function settings(): HasMany 59 | { 60 | return $this->hasMany(Setting::class, 'metadata_id'); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Eloquent/Scopes/AddMetadata.php: -------------------------------------------------------------------------------- 1 | beforeQuery(static function (QueryBuilder $builder) use ($model): void { 31 | 32 | $builder->join('user_settings_metadata', 'user_settings.metadata_id', 'user_settings_metadata.id'); 33 | 34 | if (empty($builder->columns)) { 35 | $builder->select(array_merge([$model->qualifyColumn('*')], static::getColumns())); 36 | } else { 37 | $builder->columns = (new Collection($builder->columns)) 38 | ->map([$model, 'qualifyColumn']) 39 | ->toArray(); 40 | 41 | $builder->addSelect(static::getColumns()); 42 | } 43 | }); 44 | } 45 | 46 | /** 47 | * Returns the columns to add to the query. 48 | * 49 | * @return string[] 50 | */ 51 | protected static function getColumns(): array 52 | { 53 | $model = new Metadata(); 54 | 55 | foreach ($columns = ['name', 'type', 'bag', 'default', 'group'] as $key => $column) { 56 | $columns[$key] = "{$model->qualifyColumn($column)} as $column"; 57 | } 58 | 59 | return $columns; 60 | } 61 | } -------------------------------------------------------------------------------- /src/Eloquent/Scopes/FilterBags.php: -------------------------------------------------------------------------------- 1 | whereHas('metadata', function (Builder $query): void { 31 | $query->whereIn('bag', $this->bags); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Eloquent/Scopes/WhereConfig.php: -------------------------------------------------------------------------------- 1 | macro('whereConfig', [static::class, 'whereConfig']); 34 | $builder->macro('orWhereConfig', [static::class, 'orWhereConfig']); 35 | } 36 | 37 | /** 38 | * Filters the user by the config value. 39 | * 40 | * @param \Illuminate\Database\Eloquent\Builder $builder 41 | * @param string|array $name 42 | * @param string|null $operator 43 | * @param null $value 44 | * @param string $boolean 45 | * 46 | * @return \Illuminate\Database\Eloquent\Builder 47 | */ 48 | public static function whereConfig( 49 | Builder $builder, 50 | string|array $name, 51 | string $operator = null, 52 | $value = null, 53 | string $boolean = 'and' 54 | ): Builder { 55 | if (is_array($name)) { 56 | foreach ($name as $key => $item) { 57 | if (is_array($item)) { 58 | static::whereConfig($builder, ...$item); 59 | } else { 60 | static::whereConfig($builder, $key, $item); 61 | } 62 | } 63 | 64 | return $builder; 65 | } 66 | 67 | return $builder->has( 68 | relation: 'settings', 69 | boolean: $boolean, 70 | callback: static function (Builder $builder) use ($name, $operator, $value): void { 71 | $builder 72 | ->withoutGlobalScope(AddMetadata::class) 73 | ->where( 74 | static function (Builder $builder) use ($name, $operator, $value): void { 75 | $builder 76 | ->where('value', $operator, $value) 77 | ->whereHas('metadata', static function (Builder $builder) use ($name): void { 78 | $builder->where('name', $name); 79 | }); 80 | }); 81 | }); 82 | } 83 | 84 | /** 85 | * Filters the user by the config value. 86 | * 87 | * @param \Illuminate\Database\Eloquent\Builder $builder 88 | * @param string|array $name 89 | * @param string|null $operator 90 | * @param null $value 91 | * 92 | * @return \Illuminate\Database\Eloquent\Builder 93 | */ 94 | public static function orWhereConfig( 95 | Builder $builder, 96 | string|array $name, 97 | string $operator = null, 98 | $value = null, 99 | ): Builder { 100 | return static::whereConfig($builder, $name, $operator, $value, 'or'); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Eloquent/Setting.php: -------------------------------------------------------------------------------- 1 | Casts\DynamicCasting::class, 43 | 'default' => Casts\DynamicCasting::class, 44 | 'is_enabled' => 'boolean', 45 | ]; 46 | 47 | /** 48 | * The attributes that are mass assignable. 49 | * 50 | * @var string[] 51 | */ 52 | protected $fillable = ['value', 'is_enabled']; 53 | 54 | /** 55 | * The attributes that should be visible in serialization. 56 | * 57 | * @var array 58 | */ 59 | protected $visible = ['value', 'name', 'group', 'is_disabled']; 60 | 61 | /** 62 | * Parent bags used for scoping. 63 | * 64 | * @var array|null 65 | */ 66 | public ?array $parentBags = null; 67 | 68 | /** 69 | * Settings cache repository. 70 | * 71 | * @var \DarkGhostHunter\Laraconfig\SettingsCache|null 72 | */ 73 | public ?SettingsCache $cache = null; 74 | 75 | /** 76 | * Bootstrap the model and its traits. 77 | * 78 | * @return void 79 | */ 80 | protected static function boot(): void 81 | { 82 | parent::boot(); 83 | 84 | static::updated(static function (Setting $setting): void { 85 | // Immediately after saving we will invalidate the cache of the 86 | // settings, and mark the cache ready to regenerate once there 87 | // is no more work to be done with the settings themselves. 88 | $setting->invalidateCache(); 89 | }); 90 | } 91 | 92 | /** 93 | * Perform any actions required after the model boots. 94 | * 95 | * @return void 96 | */ 97 | protected static function booted(): void 98 | { 99 | static::addGlobalScope(new Scopes\AddMetadata()); 100 | } 101 | 102 | /** 103 | * The parent metadata. 104 | * 105 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 106 | */ 107 | public function metadata(): BelongsTo 108 | { 109 | return $this->belongsTo(Metadata::class, 'metadata_id'); 110 | } 111 | 112 | /** 113 | * The user this settings belongs to. 114 | * 115 | * @return \Illuminate\Database\Eloquent\Relations\MorphTo 116 | */ 117 | public function user(): MorphTo 118 | { 119 | return $this->morphTo('settable'); 120 | } 121 | 122 | /** 123 | * Fills the settings data from a Metadata model instance. 124 | * 125 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Metadata $metadata 126 | * 127 | * @return $this 128 | */ 129 | public function fillFromMetadata(Metadata $metadata): static 130 | { 131 | return $this->forceFill( 132 | $metadata->only('name', 'type', 'default', 'group', 'bag') 133 | )->syncOriginal(); 134 | } 135 | 136 | /** 137 | * Sets a value into the setting and saves it immediately. 138 | * 139 | * @param mixed $value 140 | * @param bool $force When "false", it will be only set if its enabled. 141 | * 142 | * @return bool "true" on success, or "false" if it's disabled. 143 | */ 144 | public function set(mixed $value, bool $force = true): bool 145 | { 146 | if ($force || $this->isEnabled()) { 147 | $this->setAttribute('value', $value)->save(); 148 | } 149 | 150 | return $this->isEnabled(); 151 | } 152 | 153 | /** 154 | * Sets a value into the setting if it's enabled. 155 | * 156 | * @param mixed $value 157 | * 158 | * @return bool "true" on success, or "false" if it's disabled. 159 | */ 160 | public function setIfEnabled(mixed $value): bool 161 | { 162 | return $this->set($value, false); 163 | } 164 | 165 | /** 166 | * Reverts back the setting to its default value. 167 | * 168 | * @return void 169 | */ 170 | public function setDefault(): void 171 | { 172 | // We will retrieve the default value if it was not retrieved. 173 | if (!isset($this->attributes['default'])) { 174 | // By setting the same attribute as original we can skip saving it. 175 | // We will also use the Query Builder directly to avoid the value 176 | // being casted, as we need it raw, and let model be set as is. 177 | $this->attributes['default'] = 178 | $this->original['default'] = $this->metadata()->getQuery()->value('default'); 179 | } 180 | 181 | $this->set($this->default); 182 | } 183 | 184 | /** 185 | * Enables the setting. 186 | * 187 | * @param bool $enable 188 | * 189 | * @return void 190 | */ 191 | public function enable(bool $enable = true): void 192 | { 193 | $this->update(['is_enabled' => $enable]); 194 | } 195 | 196 | /** 197 | * Disables the setting. 198 | * 199 | * @return void 200 | */ 201 | public function disable(): void 202 | { 203 | $this->enable(false); 204 | } 205 | 206 | /** 207 | * Check if the current setting is enabled. 208 | * 209 | * @return bool 210 | */ 211 | public function isEnabled(): bool 212 | { 213 | return $this->is_enabled === true; 214 | } 215 | 216 | /** 217 | * Check if the current settings is disabled. 218 | * 219 | * @return bool 220 | */ 221 | public function isDisabled(): bool 222 | { 223 | return !$this->isEnabled(); 224 | } 225 | 226 | /** 227 | * Forcefully invalidates the cache from this setting. 228 | * 229 | * @return void 230 | */ 231 | public function invalidateCache(): void 232 | { 233 | // If an instance of the Settings Cache helper exists, we will use that. 234 | if ($this->cache) { 235 | // Invalidate the cache immediately, as is no longer representative. 236 | $this->cache->invalidateIfNotInvalidated(); 237 | // Mark the cache to be regenerated once is destructed. 238 | $this->cache->regenerateOnExit(); 239 | } elseif (config('laraconfig.cache.enable', false)) { 240 | [$morph, $id] = $this->getMorphs('settable', null, null); 241 | 242 | cache() 243 | ->store(config('laraconfig.cache.store')) 244 | ->forget( 245 | MorphManySettings::generateKeyForModel( 246 | config('laraconfig.cache.prefix'), 247 | $this->getAttribute($morph), 248 | $this->getAttribute($id) 249 | ) 250 | ); 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/Facades/Setting.php: -------------------------------------------------------------------------------- 1 | |\DarkGhostHunter\Laraconfig\Eloquent\Setting[] $settings 11 | * 12 | * @method \Illuminate\Database\Eloquent\Builder|static whereConfig(string|array $name, string $operator = null, $value = null, string $boolean = 'and') 13 | * @method \Illuminate\Database\Eloquent\Builder|static orWhereConfig(string|array $name, string $operator = null, $value = null) 14 | */ 15 | trait HasConfig 16 | { 17 | /** 18 | * Returns the settings relationship. 19 | * 20 | * @return \DarkGhostHunter\Laraconfig\MorphManySettings 21 | */ 22 | public function settings(): MorphManySettings 23 | { 24 | $instance = $this->newRelatedInstance(Eloquent\Setting::class); 25 | 26 | [$type, $id] = $this->getMorphs('settable', null, null); 27 | 28 | $table = $instance->getTable(); 29 | 30 | return new MorphManySettings( 31 | $instance->newQuery(), $this, $table.'.'.$type, $table.'.'.$id, $this->getKeyName() 32 | ); 33 | } 34 | 35 | /** 36 | * Boot the current trait. 37 | * 38 | * @return void 39 | */ 40 | protected static function bootHasConfig(): void 41 | { 42 | static::addGlobalScope(new Eloquent\Scopes\WhereConfig()); 43 | 44 | static::created( 45 | static function (Model $model): void { 46 | // If there is no method, or there is and returns true, we will initialize. 47 | if (!method_exists($model, 'shouldInitializeConfig') || $model->shouldInitializeConfig()) { 48 | $model->settings()->initialize(); 49 | } 50 | } 51 | ); 52 | 53 | static::deleting( 54 | static function (Model $model): void { 55 | // Bye settings on delete, or force-delete. 56 | if (!method_exists($model, 'isForceDeleting') || $model->isForceDeleting()) { 57 | $model->settings()->withoutGlobalScopes()->delete(); 58 | } 59 | } 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/LaraconfigServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/laraconfig.php', 'laraconfig'); 35 | 36 | $this->app->singleton(SettingRegistrar::class, static function($app): SettingRegistrar { 37 | return new SettingRegistrar( 38 | $app['config'], 39 | new Collection(), 40 | new Collection(), 41 | $app[Filesystem::class], 42 | $app 43 | ); 44 | }); 45 | } 46 | 47 | /** 48 | * Bootstrap any application services. 49 | * 50 | * @return void 51 | */ 52 | public function boot(): void 53 | { 54 | if ($this->app->runningInConsole()) { 55 | $this->commands([ 56 | Console\Commands\MigrateCommand::class, 57 | Console\Commands\PublishCommand::class, 58 | Console\Commands\CleanCommand::class, 59 | ]); 60 | 61 | $this->publishes([__DIR__.'/../config/laraconfig.php' => config_path('laraconfig.php')], 'config'); 62 | 63 | $this->publishes(iterator_to_array($this->migrationPathNames()), 'migrations'); 64 | } 65 | } 66 | 67 | /** 68 | * Returns the migration file destination path name. 69 | * 70 | * @return \Generator 71 | */ 72 | protected function migrationPathNames(): Generator 73 | { 74 | foreach (static::MIGRATION_FILES as $file) { 75 | yield $file => $this->app->databasePath( 76 | 'migrations/' . now()->format('Y_m_d_His') . Str::after($file, '00_00_00_000000') 77 | ); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Migrator/Data.php: -------------------------------------------------------------------------------- 1 | models = new Collection(); 56 | } 57 | } -------------------------------------------------------------------------------- /src/Migrator/Migrator.php: -------------------------------------------------------------------------------- 1 | input->getOption('refresh')) { 45 | if ($this->rejectedRefreshOnProduction()) { 46 | throw new RuntimeException('Settings refresh has been rejected by the user.'); 47 | } 48 | 49 | // Truncate both tables. 50 | Setting::query()->truncate(); 51 | Metadata::query()->truncate(); 52 | 53 | // Reset the metadata collection since there is nothing left. 54 | $data->metadata = new Collection(); 55 | } 56 | 57 | return $next($data); 58 | } 59 | 60 | /** 61 | * Returns if there the settings data will be refreshed and the developer has rejected that. 62 | * 63 | * @return bool 64 | */ 65 | protected function rejectedRefreshOnProduction(): bool 66 | { 67 | return $this->shouldPrompt() 68 | && !$this->output->confirm('ALL settings will be deleted completely. Proceed?'); 69 | } 70 | 71 | /** 72 | * Check if the developer should be prompted for refreshing the settings tables. 73 | * 74 | * @return bool 75 | */ 76 | protected function shouldPrompt(): bool 77 | { 78 | return Metadata::query()->exists() 79 | && $this->app->environment('production') 80 | && ! $this->input->getOption('force'); 81 | } 82 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/ConfirmSettingsToDelete.php: -------------------------------------------------------------------------------- 1 | rejectedDeleteOnProduction($data)) { 43 | throw new RuntimeException('Settings migration has been rejected by the user.'); 44 | } 45 | 46 | return $next($data); 47 | } 48 | 49 | /** 50 | * Returns if there is metadata to delete and the developer has rejected their deletion. 51 | * 52 | * @param \DarkGhostHunter\Laraconfig\Migrator\Data $data 53 | * 54 | * @return bool 55 | */ 56 | protected function rejectedDeleteOnProduction(Data $data): bool 57 | { 58 | if ($this->shouldPrompt() && $count = $this->deletableMetadata($data)) { 59 | return !$this->output->confirm( 60 | "There are $count old settings that will be deleted on sync. Proceed?" 61 | ); 62 | } 63 | 64 | return false; 65 | } 66 | 67 | /** 68 | * Counts metadata no longer listed in the manifest declarations. 69 | * 70 | * @param \DarkGhostHunter\Laraconfig\Migrator\Data $data 71 | * 72 | * @return int 73 | */ 74 | protected function deletableMetadata(Data $data): int 75 | { 76 | return $data->metadata->reject(static function (Metadata $metadata) use ($data): bool { 77 | return $data->declarations->has($metadata->name); 78 | })->count(); 79 | } 80 | 81 | /** 82 | * Check if the developer should be prompted for deleting metadata. 83 | * 84 | * @return bool 85 | */ 86 | protected function shouldPrompt(): bool 87 | { 88 | return !$this->input->getOption('refresh') 89 | && $this->app->environment('production') 90 | && !$this->input->getOption('force'); 91 | } 92 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/CreateNewMetadata.php: -------------------------------------------------------------------------------- 1 | declarationsToPersist($data); 42 | 43 | $count = 0; 44 | 45 | // Create declarations not present in the metadata 46 | if ($toPersist->isNotEmpty()) { 47 | foreach ($toPersist as $declaration) { 48 | 49 | // First, persist the declaration as metadata in the database. 50 | $metadata = $this->createMetadata($declaration); 51 | 52 | // Then, fill the settings for each user using the same metadata bag. 53 | $count += $this->fillSettingsFromMetadata($declaration, $metadata, $data->models, $data); 54 | 55 | $data->metadata->put($declaration->name, $metadata); 56 | } 57 | 58 | $data->invalidateCache = true; 59 | } 60 | 61 | $this->output->info("Added {$toPersist->count()} new settings, with $count new setting rows."); 62 | 63 | return $next($data); 64 | } 65 | 66 | /** 67 | * Creates the metadata from the declaration. 68 | * 69 | * @param \DarkGhostHunter\Laraconfig\Registrar\Declaration $declaration 70 | * 71 | * @return \DarkGhostHunter\Laraconfig\Eloquent\Metadata 72 | */ 73 | protected function createMetadata(Declaration $declaration): Metadata 74 | { 75 | return tap($declaration->toMetadata())->save(); 76 | } 77 | 78 | /** 79 | * Returns a collection of declarations that don't exist in the database. 80 | * 81 | * @param \DarkGhostHunter\Laraconfig\Migrator\Data $data 82 | * 83 | * @return \Illuminate\Support\Collection 84 | */ 85 | protected function declarationsToPersist(Data $data): Collection 86 | { 87 | return $data->declarations->reject(static function (Declaration $declaration) use ($data): bool { 88 | return $data->metadata->has($declaration->name); 89 | }); 90 | } 91 | 92 | /** 93 | * Fill the settings of the newly created Metadata. 94 | * 95 | * @param \DarkGhostHunter\Laraconfig\Registrar\Declaration $declaration 96 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Metadata $metadata 97 | * @param \Illuminate\Support\Collection $models 98 | * @param \DarkGhostHunter\Laraconfig\Migrator\Data $data 99 | * 100 | * @return int 101 | */ 102 | protected function fillSettingsFromMetadata( 103 | Declaration $declaration, 104 | Metadata $metadata, 105 | Collection $models, 106 | Data $data 107 | ): int 108 | { 109 | // If the new metadata is not using "from", we will just create the settings 110 | // for each user with just simply one query, leaving the hard work to the 111 | // database engine instead of using this script. 112 | if (!$declaration->from) { 113 | return $this->fillSettings($metadata, $models); 114 | } 115 | 116 | // If we're just using a "from", and NOT a procedure, we will copy-paste the 117 | // value of the old settings and just change the parent metadata id of each 118 | // row for the one of the new metadata. 119 | if (!$declaration->using) { 120 | return $this->copySettings($metadata, $data->metadata->get($declaration->from)); 121 | } 122 | 123 | // If we're using "from" with a procedure, we will create a new Setting with 124 | // the value of the old setting we're lazily querying, which will take time 125 | // but it will be safer than playing weird queries on the database itself. 126 | return $this->migrateSettings( 127 | $declaration, $metadata, $data->metadata->get($declaration->from) 128 | ); 129 | } 130 | 131 | /** 132 | * Fill the settings for each of the models using settings. 133 | * 134 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Metadata $metadata 135 | * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model[] $models 136 | * 137 | * @return int 138 | */ 139 | protected function fillSettings(Metadata $metadata, Collection $models): int 140 | { 141 | $affected = 0; 142 | 143 | // We will run an SQL query to create the settings for each user by 144 | // simply inserting them by each user. We will also point the ID of 145 | // both the Metadata parent and user, along with the default value. 146 | foreach ($models as $model) { 147 | $affected += Setting::query()->insertUsing( 148 | ['metadata_id', 'settable_id', 'settable_type', 'value', 'created_at', 'updated_at'], 149 | $model->newQuery() 150 | ->select([ 151 | DB::raw("'{$metadata->getKey()}' as metadata_id"), 152 | DB::raw("{$model->getKeyName()} as settable_id"), 153 | DB::raw("'". str_replace("\\\\", "\\", addcslashes($model->getMorphClass(), '\\'))."' as settable_type"), 154 | DB::raw("'{$metadata->getRawOriginal('default', 'NULL')}' as value"), 155 | DB::raw("'{$this->now->toDateTimeString()}' as created_at"), 156 | DB::raw("'{$this->now->toDateTimeString()}' as updated_at"), 157 | ])->getQuery() 158 | ); 159 | } 160 | 161 | return $affected; 162 | } 163 | 164 | /** 165 | * Copy the settings for each of the models from the old setting. 166 | * 167 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Metadata $new 168 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Metadata $old 169 | * 170 | * @return int 171 | */ 172 | protected function copySettings(Metadata $new, Metadata $old): int 173 | { 174 | // We will simply query all the settings that reference the old metadata 175 | // and "clone" each model set, but using the new metadata id. 176 | return Setting::query()->insertUsing( 177 | ['metadata_id', 'settable_id', 'settable_type', 'value', 'created_at', 'updated_at'], 178 | Setting::query()->where('metadata_id', $old->getKey()) 179 | ->select([ 180 | DB::raw("'{$new->getKey()}' as metadata_id"), 181 | 'settable_id', 182 | 'settable_type', 183 | 'value', // Here we will just instruct to copy the value raw to the new setting. 184 | DB::raw("'{$this->now->toDateTimeString()}' as created_at"), 185 | DB::raw("'{$this->now->toDateTimeString()}' as updated_at"), 186 | ])->getQuery() 187 | ); 188 | } 189 | 190 | /** 191 | * Feeds each old setting to a procedure that saves the new setting value. 192 | * 193 | * @param \DarkGhostHunter\Laraconfig\Registrar\Declaration $declaration 194 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Metadata $new 195 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Metadata $old 196 | * 197 | * @return int 198 | */ 199 | protected function migrateSettings(Declaration $declaration, Metadata $new, Metadata $old): int 200 | { 201 | $affected = 0; 202 | 203 | /** @var \DarkGhostHunter\Laraconfig\Eloquent\Setting $setting */ 204 | foreach (Setting::query()->where('metadata_id', $old->getKey())->lazyById() as $setting) { 205 | Setting::query() 206 | ->insert([ 207 | 'metadata_id' => $new->getKey(), 208 | 'settable_type' => $setting->getAttribute('settable_type'), 209 | 'settable_id' => $setting->getAttribute('settable_id'), 210 | 'is_enabled' => $setting->is_enabled, 211 | 'value' => ($declaration->using)($setting), 212 | 'created_at' => $this->now->toDateTimeString(), 213 | 'updated_at' => $this->now->toDateTimeString(), 214 | ]); 215 | 216 | $affected++; 217 | } 218 | 219 | return $affected; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/Migrator/Pipes/EnsureFromTargetsExist.php: -------------------------------------------------------------------------------- 1 | nonExistentTargets($data))) { 25 | throw new RuntimeException( 26 | 'One or more migrations have non-existent targets: '.implode(', ', $absent).'.' 27 | ); 28 | } 29 | 30 | return $next($data); 31 | } 32 | 33 | /** 34 | * Returns the targets of migrated settings that don't exists in the database. 35 | * 36 | * @return string[] 37 | */ 38 | protected function nonExistentTargets(Data $data): array 39 | { 40 | $absent = []; 41 | 42 | // Check if each migration target is contained in the database, or is declared as new. 43 | // OMFG this look like spaghetti code. What it does is relatively simple: we check if 44 | // the migration target exists in the manifest or it already exists in the database. 45 | foreach ($data->declarations as $declaration) { 46 | if ($declaration->from 47 | && !$data->declarations->has($declaration->from) 48 | && !$data->metadata->has($declaration->from)) { 49 | $absent[] = $declaration->from; 50 | } 51 | } 52 | 53 | return $absent; 54 | } 55 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/EnsureSomethingToMigrate.php: -------------------------------------------------------------------------------- 1 | metadata->isEmpty() && $data->declarations->isEmpty()) { 27 | throw new RuntimeException('No metadata exists in the database, and no declaration exists.'); 28 | } 29 | 30 | return $next($data); 31 | } 32 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/FindModelsWithSettings.php: -------------------------------------------------------------------------------- 1 | models->isEmpty()) { 48 | $data->models = new Collection(iterator_to_array($this->findModelsWithSettings())); 49 | } 50 | 51 | // If we find two or more models using the SAME table, we will bail out. 52 | // If we continue we will create settings for the same models twice or 53 | // more, which will hinder performance and may introduce bugs later. 54 | $duplicated = $data->models->map->getTable()->duplicates(); 55 | 56 | if ($duplicated->isNotEmpty()) { 57 | throw new RuntimeException("{$duplicated->count()} models are using the same tables: {$duplicated->implode(', ')}."); 58 | } 59 | 60 | return $next($data); 61 | } 62 | 63 | /** 64 | * Finds all models from the project. 65 | * 66 | * @return \Generator 67 | */ 68 | protected function findModelsWithSettings(): Generator 69 | { 70 | $namespace = $this->app->getNamespace(); 71 | 72 | if ($this->filesystem->exists($this->app->path('Models'))) { 73 | $files = $this->filesystem->allFiles($this->app->path('Models')); 74 | } 75 | 76 | // If the developer is not using the "Models", we will try the root. 77 | if (empty($files)) { 78 | $files = $this->filesystem->files($this->app->path()); 79 | } 80 | 81 | foreach ($files as $file) { 82 | $className = (string) Str::of($file->getPathname()) 83 | ->after($this->app->basePath()) 84 | ->trim('\\') 85 | ->trim('/') 86 | ->ltrim('app\\') 87 | ->replace('.php', '') 88 | ->replace(DIRECTORY_SEPARATOR, '\\') 89 | ->start('\\'.$namespace) 90 | ->replace('\\\\', '\\'); 91 | 92 | try { 93 | $reflection = new ReflectionClass($className); 94 | } catch (ReflectionException) { 95 | continue; 96 | } 97 | 98 | // Should be part of the Eloquent ORM Model class. 99 | if (! $reflection->isSubclassOf(Model::class)) { 100 | continue; 101 | } 102 | 103 | // We will exclude all models that are not instantiable, like abstracts, 104 | // as the developer may be using an abstract "User" class, extended by 105 | // other classes like "Admin", "Moderator", etc, avoiding duplicates. 106 | if (! $reflection->isInstantiable()) { 107 | continue; 108 | } 109 | 110 | // Should have the HasConfig trait, or have a trait that uses it. 111 | if (! in_array(HasConfig::class, trait_uses_recursive($className), true)) { 112 | continue; 113 | } 114 | 115 | yield new $className; 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Migrator/Pipes/FlushCache.php: -------------------------------------------------------------------------------- 1 | input->getOption('flush-cache')) { 48 | // If is not using a cache, we will not flush anything and bail. 49 | if (!$this->config->get('laraconfig.cache.enable', false)) { 50 | throw new RuntimeException('Cannot flush cache. Laraconfig cache is not enabled.'); 51 | } 52 | 53 | $store = $this->config->get('laraconfig.cache.store'); 54 | 55 | // We will prompt the user if needed, and wait for its confirmation. 56 | if ($this->shouldPrompt() && !$this->confirms($store)) { 57 | throw new RuntimeException("Flush of the $store cache has been cancelled."); 58 | } 59 | 60 | $this->factory->store($store)->flush(); 61 | } 62 | 63 | return $next($data); 64 | } 65 | 66 | /** 67 | * Check if the user should be prompted to confirm. 68 | * 69 | * @return bool 70 | */ 71 | protected function shouldPrompt(): bool 72 | { 73 | return $this->app->environment('production') 74 | && ! $this->input->getOption('force'); 75 | } 76 | 77 | /** 78 | * Confirms if the user 79 | * 80 | * @param string|null $store 81 | * 82 | * @return bool 83 | */ 84 | protected function confirms(string $store = null): bool 85 | { 86 | $store ??= $this->config->get('cache.default'); 87 | 88 | return $this->output->confirm("The cache store $store will be flushed completely. Proceed?"); 89 | } 90 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/InvalidateCache.php: -------------------------------------------------------------------------------- 1 | shouldInvalidateCacheKeys($data)) { 46 | $store = $this->config->get('cache.default'); 47 | 48 | $this->output->info( 49 | "Forgot {$this->forgetModelCacheKeys($data)} config cache keys/users from the cache store $store." 50 | ); 51 | } 52 | 53 | return $next($data); 54 | } 55 | 56 | 57 | /** 58 | * Check if we should cycle through models to invalidate their keys. 59 | * 60 | * @param \DarkGhostHunter\Laraconfig\Migrator\Data $data 61 | * 62 | * @return bool 63 | */ 64 | protected function shouldInvalidateCacheKeys(Data $data): bool 65 | { 66 | return $data->invalidateCache 67 | && ! $this->input->getOption('flush-cache') 68 | && $this->config->get('laraconfig.cache.enable'); 69 | } 70 | 71 | /** 72 | * Forget model cache keys. 73 | * 74 | * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model[] $models 75 | * 76 | * @return int 77 | */ 78 | protected function forgetModelCacheKeys(Data $data): int 79 | { 80 | $store = $this->cache->store($this->config->get('laraconfig.cache.store')); 81 | 82 | $prefix = $this->config->get('laraconfig.cache.prefix'); 83 | 84 | $count = 0; 85 | 86 | foreach ($data->models as $settable) { 87 | $morph = $settable->getMorphClass(); 88 | $keyName = $settable->getKeyName(); 89 | 90 | foreach ($this->querySettable($settable) as $model) { 91 | $key = MorphManySettings::generateKeyForModel($prefix, $morph, $model->{$keyName}); 92 | 93 | $store->forget($key); 94 | $store->forget("$key:time"); 95 | 96 | $count++; 97 | } 98 | } 99 | 100 | return $count; 101 | } 102 | 103 | /** 104 | * Returns a query to retrieve all settings distinct by type and id. 105 | * 106 | * @return \Illuminate\Support\LazyCollection|\stdClass[] 107 | */ 108 | protected function querySettable(Model $settable): LazyCollection 109 | { 110 | // We can simply query the settings table through its index of morphs. 111 | return $settable->newQuery() 112 | ->getQuery() 113 | ->select($name = $settable->getKeyName()) 114 | ->lazyById(column: $name); 115 | } 116 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/LoadDeclarations.php: -------------------------------------------------------------------------------- 1 | registrar->loadDeclarations(); 34 | 35 | // We won't overload the declarations if the data is not empty. 36 | $data->declarations = $this->registrar->getDeclarations(); 37 | 38 | return $next($data); 39 | } 40 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/LoadMetadata.php: -------------------------------------------------------------------------------- 1 | metadata = Metadata::all()->keyBy(static fn(Metadata $metadata): string => $metadata->name); 25 | 26 | return $next($data); 27 | } 28 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/RemoveOldMetadata.php: -------------------------------------------------------------------------------- 1 | toDelete($data) as $metadata) { 40 | $affected += $metadata->settings()->delete(); 41 | $metadata->delete(); 42 | $count++; 43 | 44 | $data->invalidateCache = true; 45 | } 46 | 47 | $this->output->info("Deleted $count metadata settings, with $affected settings deleted."); 48 | 49 | return $next($data); 50 | } 51 | 52 | /** 53 | * Returns a collection of Metadata not present in the manifest. 54 | * 55 | * @param \DarkGhostHunter\Laraconfig\Migrator\Data $data 56 | * 57 | * @return \Illuminate\Database\Eloquent\Collection|\DarkGhostHunter\Laraconfig\Eloquent\Metadata[] 58 | */ 59 | protected function toDelete(Data $data): Collection 60 | { 61 | return $data->metadata->reject(static function (Metadata $metadata) use ($data): bool { 62 | return $data->declarations->has($metadata->name); 63 | }); 64 | } 65 | } -------------------------------------------------------------------------------- /src/Migrator/Pipes/UpdateExistingMetadata.php: -------------------------------------------------------------------------------- 1 | getUpdatableMetadata($data); 39 | 40 | $count = 0; 41 | 42 | if ($updatable->isNotEmpty()) { 43 | foreach ($updatable as $declaration) { 44 | $this->updateMetadata($data, $declaration); 45 | } 46 | 47 | $data->invalidateCache = true; 48 | } 49 | 50 | $this->output->info("Updated {$updatable->count()} metadata in the database, with $count updated settings."); 51 | 52 | return $next($data); 53 | } 54 | 55 | /** 56 | * Returns a collection of metadata that is already present in the. 57 | * 58 | * @param \DarkGhostHunter\Laraconfig\Migrator\Data $data 59 | * 60 | * @return \Illuminate\Support\Collection|\DarkGhostHunter\Laraconfig\Registrar\Declaration[] 61 | */ 62 | protected function getUpdatableMetadata(Data $data): Collection 63 | { 64 | // We will find the declarations that exists in the database, and that are not 65 | // equal to them. If it doesn't exists, it must be created, and if it's equal 66 | // to the original metadata, no changes should be made to it. 67 | return $data->declarations->filter(static function (Declaration $declaration) use ($data): bool { 68 | /** @var \DarkGhostHunter\Laraconfig\Eloquent\Metadata $metadata */ 69 | if ($metadata = $data->metadata->get($declaration->name)) { 70 | $placeholder = $declaration->toMetadata(); 71 | 72 | return $placeholder->only('name', 'type', 'default', 'is_enabled', 'bag', 'group') 73 | !== $metadata->only('name', 'type', 'default', 'is_enabled', 'bag', 'group'); 74 | } 75 | 76 | return false; 77 | }); 78 | } 79 | 80 | /** 81 | * Updates each existing metadata from its declaration of the same name. 82 | * 83 | * @param \DarkGhostHunter\Laraconfig\Migrator\Data $data 84 | * @param \DarkGhostHunter\Laraconfig\Registrar\Declaration $declaration 85 | * 86 | * @return int 87 | */ 88 | protected function updateMetadata(Data $data, Declaration $declaration): int 89 | { 90 | /** @var \DarkGhostHunter\Laraconfig\Eloquent\Metadata $metadata */ 91 | $metadata = $data->metadata->get($declaration->name); 92 | 93 | $metadata->forceFill([ 94 | 'type' => $declaration->type, 95 | 'default' => $declaration->default, 96 | 'bag' => $declaration->bag, 97 | 'is_enabled' => $declaration->enabled, 98 | 'group' => $declaration->group, 99 | ]); 100 | 101 | $metadata->save(); 102 | 103 | // If the declaration has a procedure, we will update the settings of each user 104 | // using that. This is a great place to do it since we're already iterating on 105 | // the declarations that only need an update. 106 | if ($declaration->using) { 107 | return $this->updateSettingValues($declaration, $metadata); 108 | } 109 | 110 | return 0; 111 | } 112 | 113 | /** 114 | * Update each child setting (of each user) using the declaration procedure. 115 | * 116 | * @param \DarkGhostHunter\Laraconfig\Registrar\Declaration $declaration 117 | * @param \DarkGhostHunter\Laraconfig\Eloquent\Metadata $metadata 118 | * 119 | * @return int 120 | */ 121 | protected function updateSettingValues(Declaration $declaration, Metadata $metadata): int 122 | { 123 | // Since we're updating the settings of each user, we will just iterate over 124 | // each of them one by one and just hit the "update" button on the setting. 125 | $oldSettings = Setting::query() 126 | ->where('metadata_id', $metadata->getKey()) 127 | ->lazyById(column: 'user_settings.id'); 128 | 129 | $count = 0; 130 | 131 | foreach ($oldSettings as $setting) { 132 | $setting->update(['value' => ($declaration->using)($setting)]); 133 | $count++; 134 | } 135 | 136 | return $count; 137 | } 138 | } -------------------------------------------------------------------------------- /src/MorphManySettings.php: -------------------------------------------------------------------------------- 1 | windUp($parent); 47 | 48 | parent::__construct($query, $parent, $type, $id, $localKey); 49 | } 50 | 51 | /** 52 | * Prepares the relation instance to be handled. 53 | * 54 | * @param \Illuminate\Database\Eloquent\Model $parent 55 | * 56 | * @return void 57 | */ 58 | public function windUp(Model $parent): void 59 | { 60 | $config = app('config'); 61 | 62 | // We'll enable the cache for the settings only if is enabled in the 63 | // application config. This object will handle the cache easily and 64 | // will receive the instruction from the collection to regenerate. 65 | if ($config->get('laraconfig.cache.enable', false)) { 66 | $this->cache = SettingsCache::make($config, app(Factory::class), $parent); 67 | } 68 | 69 | // And filter the bags if the model has stated them. 70 | $this->bags = Arr::wrap( 71 | method_exists($parent, 'filterBags') ? $parent->filterBags() : $config->get('laraconfig.default', 'users') 72 | ); 73 | } 74 | 75 | /** 76 | * Get the relationship query. 77 | * 78 | * @param \Illuminate\Database\Eloquent\Builder $query 79 | * @param \Illuminate\Database\Eloquent\Builder $parentQuery 80 | * @param array|mixed $columns 81 | * @return \Illuminate\Database\Eloquent\Builder 82 | */ 83 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']): Builder 84 | { 85 | // We will add the global scope only when checking for existence. 86 | // This should appear on SELECT instead of other queries. 87 | return parent::getRelationExistenceQuery($query, $parentQuery, $columns) 88 | ->when($this->bags, static function (Builder $builder, $value): void { 89 | $builder->withGlobalScope(Eloquent\Scopes\FilterBags::class, new Eloquent\Scopes\FilterBags($value)); 90 | }); 91 | } 92 | 93 | /** 94 | * Generates the key for the model to save into the cache. 95 | * 96 | * @param string $prefix 97 | * @param string $morphClass 98 | * @param string|int $key 99 | * 100 | * @return string 101 | */ 102 | public static function generateKeyForModel(string $prefix, string $morphClass, string|int $key): string 103 | { 104 | return implode('|', [trim($prefix, '|'), $morphClass, $key]); 105 | } 106 | 107 | /** 108 | * Initializes the Settings Repository for a given user. 109 | * 110 | * @param bool $force 111 | * 112 | * @return void 113 | */ 114 | public function initialize(bool $force = false): void 115 | { 116 | if (!$force && $this->isInitialized()) { 117 | return; 118 | } 119 | 120 | // Pre-emptively delete all dangling settings from the user. 121 | $query = tap($this->getParent()->settings()->newQuery())->delete(); 122 | 123 | // Invalidate the cache immediately. 124 | $this->cache?->invalidate(); 125 | 126 | // Add the collection to the relation, avoiding retrieving them again later. 127 | $this->getParent()->setRelation('settings', $settings = new SettingsCollection()); 128 | 129 | foreach (Metadata::query()->lazyById(column: 'id') as $metadatum) { 130 | $setting = $query->make()->forceFill([ 131 | 'metadata_id' => $metadatum->getKey(), 132 | 'value' => $metadatum->default 133 | ]); 134 | 135 | $setting->saveQuietly(); 136 | 137 | // We will hide the settings not part of the model bags. 138 | if (in_array($metadatum->bag, $this->bags, true)) { 139 | $setting->bags = $this->bags; 140 | $settings->push($setting->fillFromMetadata($metadatum)); 141 | } 142 | } 143 | 144 | $settings->cache = $this->cache; 145 | } 146 | 147 | /** 148 | * Checks if the user settings has been initialized. 149 | * 150 | * @return bool 151 | */ 152 | public function isInitialized(): bool 153 | { 154 | return $this->getParent()->settings()->count() === Metadata::query()->count(); 155 | } 156 | 157 | /** 158 | * Adds a cache instance to the setting models, if there is one. 159 | * 160 | * @param \Illuminate\Database\Eloquent\Collection $settings 161 | * 162 | * @return \Illuminate\Database\Eloquent\Collection 163 | */ 164 | protected function prepareCollection(EloquentCollection $settings): EloquentCollection 165 | { 166 | return $settings->keyBy(function (Eloquent\Setting $setting): string { 167 | $setting->cache = $this->cache; 168 | $setting->parentBags = $this->bags; 169 | 170 | return $setting->name; 171 | }); 172 | } 173 | 174 | /** 175 | * Get the results of the relationship. 176 | * 177 | * @return mixed 178 | */ 179 | public function getResults(): SettingsCollection 180 | { 181 | // If the developer loads the relation, before that we will check if 182 | // the cache is enabled. If that's the case, we will try first to 183 | // retrieve them from the cache store before hitting the table. 184 | $collection = new SettingsCollection( 185 | $this->prepareCollection($this->cache?->retrieve() ?? parent::getResults())->all() 186 | ); 187 | 188 | if ($this->cache) { 189 | $collection->cache = $this->cache; 190 | $this->cache->setSettings($collection); 191 | } 192 | 193 | return $collection; 194 | } 195 | 196 | /** 197 | * Returns all the bags being used by the model. 198 | * 199 | * @return array 200 | */ 201 | public function bags(): array 202 | { 203 | return $this->bags; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Registrar/Declaration.php: -------------------------------------------------------------------------------- 1 | type = Metadata::TYPE_STRING; 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * Sets the setting type as a boolean. 88 | * 89 | * @return $this 90 | */ 91 | public function boolean(): static 92 | { 93 | $this->type = Metadata::TYPE_BOOLEAN; 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * Sets the setting type as 'integer'. 100 | * 101 | * @return $this 102 | */ 103 | public function integer(): static 104 | { 105 | $this->type = Metadata::TYPE_INTEGER; 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * Sets the setting type as float/decimal. 112 | * 113 | * @return $this 114 | */ 115 | public function float(): static 116 | { 117 | $this->type = Metadata::TYPE_FLOAT; 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * Sets the setting type as an array. 124 | * 125 | * @return $this 126 | */ 127 | public function array(): static 128 | { 129 | $this->type = Metadata::TYPE_ARRAY; 130 | 131 | return $this; 132 | } 133 | 134 | /** 135 | * Sets the setting type as Datetime (Carbon). 136 | * 137 | * @return $this 138 | */ 139 | public function datetime(): static 140 | { 141 | $this->type = Metadata::TYPE_DATETIME; 142 | 143 | return $this; 144 | } 145 | 146 | /** 147 | * Sets the setting type as 'collection'. 148 | * 149 | * @return $this 150 | */ 151 | public function collection(): static 152 | { 153 | $this->type = Metadata::TYPE_COLLECTION; 154 | 155 | return $this; 156 | } 157 | 158 | /** 159 | * Sets the default value 160 | * 161 | * @param mixed $value 162 | * 163 | * @return $this 164 | */ 165 | public function default(mixed $value): static 166 | { 167 | $this->default = $value; 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * Sets the setting as disabled by default. 174 | * 175 | * @return $this 176 | */ 177 | public function disabled(bool $enabled = false): static 178 | { 179 | $this->enabled = $enabled; 180 | 181 | return $this; 182 | } 183 | 184 | /** 185 | * Sets the group this setting belongs to. 186 | * 187 | * @param string $name 188 | * 189 | * @return $this 190 | */ 191 | public function group(string $name): static 192 | { 193 | $this->group = $name; 194 | 195 | return $this; 196 | } 197 | 198 | /** 199 | * Sets the bag for declaration. 200 | * 201 | * @param string $name 202 | * 203 | * @return $this 204 | */ 205 | public function bag(string $name): static 206 | { 207 | $this->bag = $name; 208 | 209 | return $this; 210 | } 211 | 212 | /** 213 | * Migrates the value from an old setting. 214 | * 215 | * @param string $oldSetting 216 | * 217 | * @return $this 218 | */ 219 | public function from(string $oldSetting): static 220 | { 221 | $this->from = $oldSetting; 222 | 223 | return $this; 224 | } 225 | 226 | /** 227 | * Registers a callback to migrate the old value to the new one. 228 | * 229 | * @param \Closure $callback 230 | * 231 | * @return $this 232 | */ 233 | public function using(Closure $callback): static 234 | { 235 | $this->using = $callback; 236 | 237 | return $this; 238 | } 239 | 240 | /** 241 | * Transforms the Declaration to a Metadata Model. 242 | * 243 | * @return \DarkGhostHunter\Laraconfig\Eloquent\Metadata 244 | */ 245 | public function toMetadata(): Metadata 246 | { 247 | return (new Metadata)->forceFill([ 248 | 'name' => $this->name, 249 | 'type' => $this->type, 250 | 'default' => $this->default, 251 | 'bag' => $this->bag, 252 | 'group' => $this->group, 253 | 'is_enabled' => $this->enabled 254 | ]); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/Registrar/SettingRegistrar.php: -------------------------------------------------------------------------------- 1 | manifestsPath = $this->app->basePath(static::MANIFEST_DIR); 54 | } 55 | 56 | /** 57 | * Load the declarations from the manifests. 58 | * 59 | * @return void 60 | */ 61 | public function loadDeclarations(): void 62 | { 63 | // IF the directory doesn't exists, we won't bulge with reading files. 64 | if ($this->filesystem->exists($this->app->basePath('settings'))) { 65 | $files = $this->filesystem->allFiles($this->manifestsPath); 66 | 67 | $this->manifestsLoaded = ! empty($files); 68 | 69 | foreach ($files as $file) { 70 | require $file->getPathname(); 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * Returns the settings collection. 77 | * 78 | * @return \Illuminate\Support\Collection 79 | */ 80 | public function getDeclarations(): Collection 81 | { 82 | return $this->declarations; 83 | } 84 | 85 | /** 86 | * Returns a collection of declaration that migrates to another. 87 | * 88 | * @return \Illuminate\Support\Collection|\DarkGhostHunter\Laraconfig\Registrar\Declaration[] 89 | */ 90 | public function getMigrable(): Collection 91 | { 92 | return $this->getDeclarations() 93 | ->filter(static fn (Declaration $declaration): bool => null !== $declaration->from); 94 | } 95 | 96 | /** 97 | * Creates a new declaration. 98 | * 99 | * @param string $name 100 | * 101 | * @return \DarkGhostHunter\Laraconfig\Registrar\Declaration 102 | */ 103 | public function name(string $name): Declaration 104 | { 105 | $this->declarations->put($name, $declaration = new Declaration($name, $this->config->get('laraconfig.default'))); 106 | 107 | return $declaration; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/SettingsCache.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Returns the collection in the cache, if it exists. 61 | * 62 | * @return \Illuminate\Database\Eloquent\Collection|null 63 | */ 64 | public function retrieve(): ?Collection 65 | { 66 | return $this->cache->get($this->key); 67 | } 68 | 69 | /** 70 | * Check if the cache of the settings not is older these settings. 71 | * 72 | * @return bool 73 | */ 74 | public function shouldRegenerate(): bool 75 | { 76 | // If the time doesn't exist in the cache then we can safely store. 77 | if (!$time = $this->cache->get("$this->key:time")) { 78 | return true; 79 | } 80 | 81 | // Return if the is an invalidation (data changed) and is fresher. 82 | return (bool) $this->invalidatedAt?->isAfter($time); 83 | } 84 | 85 | /** 86 | * Saves the collection of settings in the cache. 87 | * 88 | * @param bool $force 89 | * 90 | * @return void 91 | */ 92 | public function regenerate(bool $force = false): void 93 | { 94 | if ($force || $this->shouldRegenerate()) { 95 | $this->cache->setMultiple([ 96 | $this->key => $this->settings, 97 | "$this->key:time" => now(), 98 | ], $this->ttl); 99 | } 100 | } 101 | 102 | /** 103 | * Invalidates the cache of the setting's user. 104 | * 105 | * @return void 106 | */ 107 | public function invalidate(): void 108 | { 109 | $this->cache->forget($this->key); 110 | $this->cache->forget("$this->key:time"); 111 | 112 | // Update the time of the last invalidation. 113 | $this->invalidatedAt = now(); 114 | } 115 | 116 | /** 117 | * Invalidate the settings cache if it has not been done before. 118 | * 119 | * @return void 120 | */ 121 | public function invalidateIfNotInvalidated(): void 122 | { 123 | if (! $this->invalidatedAt) { 124 | $this->invalidate(); 125 | } 126 | } 127 | 128 | /** 129 | * Marks the settings cache to regenerate on exit. 130 | * 131 | * @return void 132 | */ 133 | public function regenerateOnExit(): void 134 | { 135 | // Just a simple trick to regenerate only if it's enabled. 136 | $this->settings->regeneratesOnExit = $this->automaticRegeneration; 137 | } 138 | 139 | /** 140 | * representation of object. 141 | * 142 | * @return array 143 | */ 144 | public function __serialize(): array 145 | { 146 | return []; // Do not serialize this. 147 | } 148 | 149 | /** 150 | * Constructs the object. 151 | * 152 | * @param string $data 153 | * 154 | * @return void 155 | */ 156 | public function __unserialize($data): void 157 | { 158 | // Don't unserialize from anything. 159 | } 160 | 161 | /** 162 | * String representation of object. 163 | * 164 | * @return string|null 165 | */ 166 | public function serialize(): ?string 167 | { 168 | return null; // Do not serialize this. 169 | } 170 | 171 | /** 172 | * Constructs the object. 173 | * 174 | * @param string $data 175 | * 176 | * @return void 177 | */ 178 | public function unserialize($data): void 179 | { 180 | // Don't unserialize from anything. 181 | } 182 | 183 | /** 184 | * Creates a new instance. 185 | * 186 | * @param \Illuminate\Contracts\Config\Repository $config 187 | * @param \Illuminate\Contracts\Cache\Factory $factory 188 | * @param \Illuminate\Database\Eloquent\Model $model 189 | * 190 | * @return static 191 | */ 192 | public static function make(Config $config, Factory $factory, Model $model): static 193 | { 194 | return new static( 195 | $factory->store($config->get('laraconfig.cache.store')), 196 | MorphManySettings::generateKeyForModel( 197 | $config->get('laraconfig.cache.prefix', 'laraconfig'), 198 | $model->getMorphClass(), 199 | $model->getKey() 200 | ), 201 | $config->get('laraconfig.cache.ttl', 60 * 60 * 3), 202 | $config->get('laraconfig.cache.automatic', true), 203 | ); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/SettingsCollection.php: -------------------------------------------------------------------------------- 1 | groupBy('group'); 51 | } 52 | 53 | /** 54 | * Returns the value of a setting. 55 | * 56 | * @param string $name 57 | * @param mixed|null $default 58 | * 59 | * @return \Illuminate\Support\Carbon|\Illuminate\Support\Collection|array|string|int|float|bool|null 60 | */ 61 | public function value(string $name, mixed $default = null): Carbon|Collection|array|string|int|float|bool|null 62 | { 63 | $setting = $this->get($name, $default); 64 | 65 | if ($setting instanceof Eloquent\Setting) { 66 | return $setting->value; 67 | } 68 | 69 | return $setting; 70 | } 71 | 72 | /** 73 | * Checks if the value of a setting is the same as the one issued. 74 | * 75 | * @param string $name 76 | * @param mixed $value 77 | * 78 | * @return bool 79 | */ 80 | public function is(string $name, mixed $value): bool 81 | { 82 | return $this->value($name) === $value; 83 | } 84 | 85 | /** 86 | * Sets one or multiple setting values. 87 | * 88 | * @param string|array $name 89 | * @param mixed $value 90 | * @param bool $force 91 | * 92 | * @return void 93 | */ 94 | public function set(string|array $name, mixed $value = null, bool $force = true): void 95 | { 96 | // If the name is not an array, we will make it one to iterate over. 97 | if (is_string($name)) { 98 | $name = [$name => $value]; 99 | } 100 | 101 | foreach ($name as $key => $setting) { 102 | if (! $instance = $this->get($key)) { 103 | throw new RuntimeException("The setting [$key] doesn't exist."); 104 | } 105 | 106 | $instance->set($setting, $force); 107 | } 108 | } 109 | 110 | /** 111 | * Sets the default value of a given setting. 112 | * 113 | * @param string $name 114 | * 115 | * @return void 116 | */ 117 | public function setDefault(string $name): void 118 | { 119 | $this->get($name)->setDefault(); 120 | } 121 | 122 | /** 123 | * Checks if the setting is using a null value. 124 | * 125 | * @param string $name 126 | * 127 | * @return bool 128 | */ 129 | public function isNull(string $name): bool 130 | { 131 | return null === $this->value($name); 132 | } 133 | 134 | /** 135 | * Checks if the Setting is enabled. 136 | * 137 | * @param string $name 138 | * 139 | * @return bool 140 | */ 141 | public function isEnabled(string $name): bool 142 | { 143 | return $this->get($name)->is_enabled === true; 144 | } 145 | 146 | /** 147 | * Checks if the Setting is disabled. 148 | * 149 | * @param string $name 150 | * 151 | * @return bool 152 | */ 153 | public function isDisabled(string $name): bool 154 | { 155 | return ! $this->isEnabled($name); 156 | } 157 | 158 | /** 159 | * Disables a Setting. 160 | * 161 | * @param string $name 162 | * 163 | * @return void 164 | */ 165 | public function disable(string $name): void 166 | { 167 | $this->get($name)->disable(); 168 | } 169 | 170 | /** 171 | * Enables a Setting. 172 | * 173 | * @param string $name 174 | * 175 | * @return void 176 | */ 177 | public function enable(string $name): void 178 | { 179 | $this->get($name)->enable(); 180 | } 181 | 182 | /** 183 | * Sets a value into a setting if it exists and it's enabled. 184 | * 185 | * @param string|array $name 186 | * @param mixed $value 187 | * 188 | * @return void 189 | */ 190 | public function setIfEnabled(string|array $name, mixed $value = null): void 191 | { 192 | $this->set($name, $value, false); 193 | } 194 | 195 | /** 196 | * Returns only the models from the collection with the specified keys. 197 | * 198 | * @param mixed $keys 199 | * @return static 200 | */ 201 | public function only($keys): static 202 | { 203 | if (is_null($keys)) { 204 | return new static($this->items); 205 | } 206 | 207 | if ($keys instanceof Enumerable) { 208 | $keys = $keys->all(); 209 | } 210 | 211 | $keys = is_array($keys) ? $keys : func_get_args(); 212 | 213 | $settings = new static(Arr::only($this->items, Arr::wrap($keys))); 214 | 215 | if ($settings->isNotEmpty()) { 216 | return $settings; 217 | } 218 | 219 | return parent::only($keys); 220 | } 221 | 222 | /** 223 | * Returns all models in the collection except the models with specified keys. 224 | * 225 | * @param mixed $keys 226 | * @return static 227 | */ 228 | public function except($keys): static 229 | { 230 | if ($keys instanceof Enumerable) { 231 | $keys = $keys->all(); 232 | } elseif (! is_array($keys)) { 233 | $keys = func_get_args(); 234 | } 235 | 236 | $settings = new static(Arr::except($this->items, $keys)); 237 | 238 | if ($settings->isNotEmpty()) { 239 | return $settings; 240 | } 241 | 242 | return parent::except($keys); 243 | } 244 | 245 | /** 246 | * Invalidates the cache of the setting's user. 247 | * 248 | * @return void 249 | */ 250 | public function invalidate(): void 251 | { 252 | $this->cache?->invalidate(); 253 | } 254 | 255 | /** 256 | * Invalidate the settings cache if it has not been done before. 257 | * 258 | * @return void 259 | */ 260 | public function invalidateIfNotInvalidated(): void 261 | { 262 | $this->cache?->invalidateIfNotInvalidated(); 263 | } 264 | 265 | /** 266 | * Saves the collection of settings in the cache. 267 | * 268 | * @param bool $force 269 | * 270 | * @return void 271 | */ 272 | public function regenerate(bool $force = false): void 273 | { 274 | $this->cache?->regenerate($force); 275 | } 276 | 277 | /** 278 | * Handle the destruction of the settings collection. 279 | * 280 | * @return void 281 | */ 282 | public function __destruct() 283 | { 284 | if ($this->regeneratesOnExit) { 285 | $this->cache?->setSettings($this)->regenerate(); 286 | } 287 | } 288 | 289 | /** 290 | * Dynamically sets a value. 291 | * 292 | * @param string $name 293 | * @param mixed $value 294 | */ 295 | public function __set(string $name, mixed $value): void 296 | { 297 | $this->set($name, $value); 298 | } 299 | 300 | /** 301 | * Check if a given property exists. 302 | * 303 | * @param string $name 304 | * 305 | * @return bool 306 | */ 307 | public function __isset(string $name): bool 308 | { 309 | return $this->has($name); 310 | } 311 | 312 | /** 313 | * Dynamically access collection proxies. 314 | * 315 | * @param string $key 316 | * @return mixed 317 | * 318 | * @throws \Exception 319 | */ 320 | public function __get($key): mixed 321 | { 322 | if ($setting = $this->get($key)) { 323 | return $setting->getAttribute('value'); 324 | } 325 | 326 | return $this->__dynamicget($key); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /stubs/users.php: -------------------------------------------------------------------------------- 1 | boolean(); 6 | --------------------------------------------------------------------------------