├── .gitignore ├── FUNDING.yml ├── LICENSE ├── README.md ├── composer.json ├── database └── migrations │ └── 2022_11_25_213710_create_settings_table.php ├── src ├── Facades │ ├── Accessors │ │ └── SettingsAccessor.php │ └── Settings.php ├── Models │ ├── AbstractSetting.php │ └── BaseSetting.php ├── SettingsContainer.php ├── SettingsServiceProvider.php └── Traits │ ├── CastsToType.php │ ├── HasSettings.php │ └── HasSettingsDefinitions.php └── stubs └── Setting.stub /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.phar 3 | composer.lock 4 | .phpunit.result.cache 5 | .DS_Store 6 | .idea* 7 | .php-cs-fixer.cache -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: npabisz 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Norbert Pabisz 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Settings for Laravel 2 | 3 | Basic settings for Laravel 9+. Can be either global or morphed to models. 4 | 5 | ## Requirements 6 | * PHP >= 8.1 7 | * Laravel >= 9.0 8 | 9 | ## Installation 10 | 11 | ```bash 12 | composer require npabisz/laravel-settings 13 | ``` 14 | 15 | Then publish vendor resources and migration 16 | 17 | ```bash 18 | php artisan vendor:publish --provider="Npabisz\LaravelSettings\SettingsServiceProvider" 19 | ``` 20 | 21 | Finally, you should run migration 22 | 23 | ```bash 24 | php artisan migrate 25 | ``` 26 | 27 | ## Basic usage 28 | 29 | Example of `User` model which uses `HasSettings` trait is simple as that: 30 | 31 | ```php 32 | // Get value of `is_gamer` setting 33 | $user->settings->get('is_gamer'); 34 | 35 | // Set value of `games_count` setting 36 | $user->settings->set('games_count', 10); 37 | 38 | // Get user address object 39 | $address = $user->settings->get('address'); 40 | echo "User is from $address->city"; 41 | ``` 42 | 43 | You can use it with enums 44 | 45 | ```php 46 | enum Settings: string { 47 | case ApiMode = 'api_mode'; 48 | case Enabled = 'enabled'; 49 | } 50 | 51 | enum ApiMode: string { 52 | case Production = 'production'; 53 | case Sandbox = 'sandbox'; 54 | } 55 | 56 | $user->settings->set(Settings::ApiMode, ApiMode::Production); 57 | ``` 58 | 59 | You are not limited to `User` model, this works on every model: 60 | 61 | ```php 62 | // Check if article is premium 63 | $article->settings->get('is_premium'); 64 | ``` 65 | 66 | There are also different ways and scopes to access: 67 | 68 | ```php 69 | use Npabisz\LaravelSettings\Facades\Settings; 70 | 71 | // Get global website setting value 72 | $value = Settings::get('api_mode'); 73 | 74 | // Update global website setting value 75 | Settings::set('api_mode', 'production'); 76 | 77 | // Access to the Setting model 78 | $settingModel = Settings::setting('api_mode'); 79 | $settingModel->delete(); 80 | 81 | // Get all global website settings models 82 | $settingModels = Settings::all(); 83 | 84 | // Get all global website settings models, 85 | // but filling the missing ones with default values 86 | $settingModels = Settings::allWithDefaults(); 87 | 88 | foreach ($settingModels as $setting) { 89 | if (null === $setting->id) { 90 | // This one isn't existing in database 91 | // and has default value based on definition 92 | } 93 | } 94 | ``` 95 | 96 | ## Scoping to models 97 | 98 | ```php 99 | use Npabisz\LaravelSettings\Facades\Settings; 100 | 101 | // Local scope for model 102 | $model = User::first(); 103 | $userSettings = Settings::scope($model); 104 | 105 | // Get user setting value 106 | $value = $userSettings->get('is_newsletter_opted_in'); 107 | 108 | // Set user setting value 109 | $userSettings->set('is_newsletter_opted_in', true); 110 | 111 | // You can use any model which implements HasSettings trait 112 | // Local scope for article 113 | $article = Article::first(); 114 | $articleSettings = Settings::scope($article); 115 | 116 | // Get article setting value 117 | $articleSettings->get('enable_promo_banner'); 118 | 119 | // Set article setting value 120 | $articleSettings->set('enable_promo_banner', true); 121 | ``` 122 | 123 | ## Using global scopes 124 | 125 | It easier to use global scope for models that won't change during request, eg. logged in user. Which is globally scoped by default, but here is example how to do it manually, eg. you need to persist scope in command. 126 | 127 | ```php 128 | use Npabisz\LaravelSettings\Facades\Settings; 129 | 130 | // Global scope for model 131 | $user = User::first(); 132 | Settings::scopeGlobal($user); 133 | 134 | // Now you can call magic method and 135 | // it will return SettingsContainer 136 | // for scoped user 137 | Settings::user()->get('is_gamer'); 138 | Settings::user()->set('is_gamer', false); 139 | 140 | // Replace scope 141 | $anotherUser = User::find(2); 142 | Settings::scopeGlobal($anotherUser); 143 | 144 | // Now settings returned by user() 145 | // method belongs to $anotherUser 146 | Settings::user()->set('is_gamer', true); 147 | 148 | // You can scope any model which 149 | // has HasSettings trait 150 | $article = Article::first(); 151 | Settings::scopeGlobal($article); 152 | 153 | // Now you can access them via 154 | // magic method named after class name 155 | Settings::article()->get('is_premium'); 156 | ``` 157 | 158 | ## Settings definitions 159 | 160 | Every setting has to have definition. This way it is always of the same type and can have default values. Settings definitions are declared under static method `getSettingsDefinitions`. 161 | 162 | > ### Remember 163 | > Global settings are defined on `Setting` model. 164 | 165 | ```php 166 | use Npabisz\LaravelSettings\Models\AbstractSetting; 167 | 168 | class Setting extends AbstractSetting 169 | { 170 | /** 171 | * @return array 172 | */ 173 | public static function getSettingsDefinitions (): array 174 | { 175 | return [ 176 | [ 177 | // Setting name which will be unique 178 | 'name' => 'api_mode', 179 | // Default value for setting 180 | 'default' => 'sandbox', 181 | // You can optionally specify valid values 182 | 'options' => [ 183 | 'production', 184 | 'sandbox', 185 | ], 186 | ], 187 | [ 188 | // Another setting name 189 | 'name' => 'is_enabled', 190 | // You can optionally specify setting cast 191 | 'cast' => 'bool', 192 | // Default value for setting 193 | 'default' => false, 194 | ], 195 | [ 196 | // Another setting name 197 | 'name' => 'address', 198 | // You can use classes which will be stored as json 199 | 'cast' => Address::class, 200 | ], 201 | ]; 202 | } 203 | } 204 | ``` 205 | 206 | Instead of storing each field of address in separate setting. You can use class which will be then casted to json. 207 | 208 | ```php 209 | use Npabisz\LaravelSettings\Models\BaseSetting; 210 | 211 | class Address extends BaseSetting 212 | { 213 | /** 214 | * @var string 215 | */ 216 | public string $street; 217 | 218 | /** 219 | * @var string 220 | */ 221 | public string $zipcode; 222 | 223 | /** 224 | * @var string 225 | */ 226 | public string $city; 227 | 228 | public function __construct () 229 | { 230 | // You can specify default values 231 | $this->street = ''; 232 | $this->zipcode = ''; 233 | $this->city = ''; 234 | } 235 | 236 | /** 237 | * This method will be used to populate 238 | * data from json object. 239 | * 240 | * @param array $data 241 | */ 242 | public function fromArray (array $data) 243 | { 244 | $this->street = $data['street'] ?? ''; 245 | $this->zipcode = $data['zipcode'] ?? ''; 246 | $this->city = $data['city'] ?? ''; 247 | } 248 | 249 | /** 250 | * @return array 251 | */ 252 | public function toArray (): array 253 | { 254 | return [ 255 | 'street' => $this->street, 256 | 'zipcode' => $this->zipcode, 257 | 'city' => $this->city, 258 | ]; 259 | } 260 | } 261 | ``` 262 | 263 | Example of using enums 264 | 265 | ```php 266 | enum Settings: string { 267 | case ApiMode = 'api_mode'; 268 | case Enabled = 'enabled'; 269 | } 270 | ``` 271 | ```php 272 | enum ApiMode: string { 273 | case Production = 'production'; 274 | case Sandbox = 'sandbox'; 275 | } 276 | ``` 277 | ```php 278 | use Npabisz\LaravelSettings\Models\AbstractSetting; 279 | 280 | use App\Enums\Settings; 281 | use App\Enums\ApiMode; 282 | 283 | class Setting extends AbstractSetting 284 | { 285 | /** 286 | * @return array 287 | */ 288 | public static function getSettingsDefinitions (): array 289 | { 290 | return [ 291 | [ 292 | 'name' => Settings::ApiMode, 293 | 'default' => ApiMode::Sandbox, 294 | 'enum' => ApiMode::class, 295 | ], 296 | [ 297 | 'name' => Settings::Enabled, 298 | 'cast' => 'bool', 299 | 'default' => false, 300 | ] 301 | ]; 302 | } 303 | } 304 | ``` 305 | 306 | Example of `nullable` setting 307 | 308 | ```php 309 | use Npabisz\LaravelSettings\Models\AbstractSetting; 310 | 311 | class Setting extends AbstractSetting 312 | { 313 | /** 314 | * @return array 315 | */ 316 | public static function getSettingsDefinitions (): array 317 | { 318 | return [ 319 | [ 320 | 'name' => 'display_name', 321 | 'is_nullable' => true, 322 | ] 323 | ]; 324 | } 325 | } 326 | ``` 327 | 328 | ## Models 329 | 330 | If you want to have settings on model you need to use `HasSettings` trait and declare some settings definitions. 331 | 332 | ```php 333 | use Npabisz\LaravelSettings\Traits\HasSettings; 334 | 335 | class User extends Authenticatable 336 | { 337 | use HasSettings; 338 | 339 | ... 340 | 341 | /** 342 | * @return array 343 | */ 344 | public static function getSettingsDefinitions(): array 345 | { 346 | return [ 347 | [ 348 | 'name' => 'theme_mode', 349 | 'default' => 'light', 350 | 'options' => [ 351 | 'light', 352 | 'dark', 353 | ], 354 | ], 355 | [ 356 | 'name' => 'is_gamer', 357 | 'cast' => 'bool', 358 | ], 359 | [ 360 | 'name' => 'games_count', 361 | 'cast' => 'int', 362 | ], 363 | ]; 364 | } 365 | } 366 | ``` 367 | 368 | Now you can get their settings. 369 | 370 | ```php 371 | use Npabisz\LaravelSettings\Facades\Settings; 372 | 373 | // Assuming user is logged in 374 | Settings::user()->get('is_gamer'); 375 | Settings::user()->set('games_count', 10); 376 | 377 | // You can also access settings via property 378 | $user->settings->get('is_gamer'); 379 | $user->settings->set('games_count', 10); 380 | ``` 381 | 382 | ## License 383 | 384 | `npabisz/laravel-settings` is released under the MIT License. See the bundled [LICENSE](./LICENSE) for details. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "npabisz/laravel-settings", 3 | "description": "Settings for Laravel.", 4 | "keywords": ["laravel", "settings"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Norbert Pabisz", 9 | "email": "yorki.ogame@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1|^8.2|^8.3|^8.4", 14 | "ext-json": "*", 15 | "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", 16 | "illuminate/database": "^9.0|^10.0|^11.0|^12.0", 17 | "illuminate/support": "^9.0|^10.0|^11.0|^12.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Npabisz\\LaravelSettings\\": "src/" 22 | } 23 | }, 24 | "extra": { 25 | "laravel": { 26 | "providers": [ 27 | "Npabisz\\LaravelSettings\\SettingsServiceProvider" 28 | ], 29 | "aliases": { 30 | "Settings": "Npabisz\\LaravelSettings\\Facades\\Settings" 31 | } 32 | } 33 | }, 34 | "prefer-stable": true 35 | } -------------------------------------------------------------------------------- /database/migrations/2022_11_25_213710_create_settings_table.php: -------------------------------------------------------------------------------- 1 | collation = 'utf8_general_ci'; 18 | $table->charset = 'utf8'; 19 | 20 | $table->id(); 21 | $table->timestamps(); 22 | 23 | $table->bigInteger('settingable_id')->nullable(); 24 | $table->string('settingable_type')->nullable(); 25 | $table->string('name'); 26 | $table->text('value')->nullable(); 27 | 28 | $table->unique(['settingable_id', 'settingable_type', 'name']); 29 | }); 30 | } 31 | 32 | /** 33 | * Reverse the migrations. 34 | * 35 | * @return void 36 | */ 37 | public function down() 38 | { 39 | Schema::dropIfExists('settings'); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/Facades/Accessors/SettingsAccessor.php: -------------------------------------------------------------------------------- 1 | settings = new SettingsContainer(); 31 | 32 | if ($model !== null) { 33 | $this->scopeGlobal($model); 34 | } 35 | } 36 | 37 | /** 38 | * @param Model $model 39 | * 40 | * @throws \Exception 41 | * 42 | * @return SettingsContainer 43 | */ 44 | public function scope (Model $model): SettingsContainer 45 | { 46 | $scoped = $this->scopedSettings[get_class($model)] ?? null; 47 | 48 | if ($scoped && $scoped->isScopedTo($model)) { 49 | return $scoped; 50 | } 51 | 52 | return new SettingsContainer($model); 53 | } 54 | 55 | /** 56 | * @param Model $model 57 | * 58 | * @throws \Exception 59 | * 60 | * @return SettingsContainer 61 | */ 62 | public function scopeGlobal (Model $model): SettingsContainer 63 | { 64 | return $this->scopedSettings[get_class($model)] = new SettingsContainer($model, true); 65 | } 66 | 67 | /** 68 | * @param string $method 69 | * @param array $arguments 70 | * 71 | * @throws \Exception 72 | * 73 | * @return mixed 74 | */ 75 | public function __call ($method, $arguments) 76 | { 77 | try { 78 | return $this->forwardCallTo($this->settings, $method, $arguments); 79 | } catch (Error|BadMethodCallException $e) { 80 | foreach ($this->scopedSettings as $class => $container) { 81 | $shortName = (new \ReflectionClass($class))->getShortName(); 82 | 83 | if (strtolower($shortName) === $method) { 84 | return $this->scopedSettings[$class]; 85 | } 86 | } 87 | 88 | if (!empty($arguments) && is_object($arguments[0]) && $arguments[0] instanceof Model) { 89 | return $this->scope($arguments[0]); 90 | } 91 | 92 | throw new \Exception('Tried to access scope which isn\'t initialized'); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Facades/Settings.php: -------------------------------------------------------------------------------- 1 | 'integer', 37 | ]; 38 | 39 | /** 40 | * @return MorphTo 41 | */ 42 | public function settingable (): MorphTo 43 | { 44 | return $this->morphTo('settingsRelation'); 45 | } 46 | 47 | /** 48 | * @return Attribute 49 | */ 50 | public function value(): Attribute 51 | { 52 | return Attribute::make( 53 | get: function ($value, $attributes) { 54 | $definition = static::getSettingDefinition($attributes['name']); 55 | 56 | if ($attributes['settingable_type']) { 57 | $definition = $attributes['settingable_type']::getSettingDefinition($attributes['name']); 58 | } 59 | 60 | if (!empty($definition['enum'])) { 61 | return $this->castToType($definition['enum'], $value); 62 | } 63 | 64 | if (empty($definition['cast'])) { 65 | return $value; 66 | } 67 | 68 | return $this->castToType($definition['cast'], $value); 69 | }, 70 | set: function ($value, $attributes) { 71 | $definition = self::getSettingDefinition($attributes['name']); 72 | 73 | if ($attributes['settingable_type']) { 74 | $definition = $attributes['settingable_type']::getSettingDefinition($attributes['name']); 75 | } 76 | 77 | if (!empty($definition['enum'])) { 78 | $this->setAttributeByType($definition['enum'], 'value', $value); 79 | 80 | return $this->attributes['value']; 81 | } 82 | 83 | if (empty($definition['cast'])) { 84 | return $value; 85 | } 86 | 87 | $this->setAttributeByType($definition['cast'], 'value', $value); 88 | 89 | return $this->attributes['value']; 90 | } 91 | ); 92 | } 93 | 94 | /** 95 | * @param \BackedEnum|string $name 96 | * 97 | * @return array|null 98 | */ 99 | public static function getSettingDefinition (\BackedEnum|string $name): ?array 100 | { 101 | $definitions = static::getSettingsDefinitions(); 102 | $stringName = $name instanceof \BackedEnum 103 | ? $name->value 104 | : $name; 105 | 106 | foreach ($definitions as $definition) { 107 | $definitionName = $definition['name'] instanceof \BackedEnum 108 | ? $definition['name']->value 109 | : $definition['name']; 110 | 111 | if ($definitionName === $stringName) { 112 | return $definition; 113 | } 114 | } 115 | 116 | return null; 117 | } 118 | 119 | /** 120 | * @return array 121 | */ 122 | public function getSettingOptions (): array 123 | { 124 | $definition = self::getSettingDefinition($this->name); 125 | 126 | if ($this->settingable_type) { 127 | $definition = $this->settingable_type::getSettingDefinition($this->name); 128 | } 129 | 130 | if (!empty($definition['enum'])) { 131 | return array_map(function ($item) use ($definition) { 132 | if ((new \ReflectionEnum($definition['enum']))->isBacked()) { 133 | return $item->value; 134 | } else { 135 | return $item->name; 136 | } 137 | }, $definition['enum']::cases()); 138 | } 139 | 140 | if (isset($definition['options'])) { 141 | return $definition['options']; 142 | } 143 | 144 | return []; 145 | } 146 | 147 | /** 148 | * @return array 149 | */ 150 | public static function getGlobalSettingsDefinitions(): array 151 | { 152 | return static::getSettingsDefinitions(); 153 | } 154 | 155 | /** 156 | * Merge the cast class attributes back into the model. 157 | * 158 | * @return void 159 | */ 160 | protected function mergeAttributesFromClassCasts () 161 | { 162 | foreach ($this->classCastCache as $key => $value) { 163 | if ($key === 'value') { 164 | $definition = self::getSettingDefinition($this->attributes['name']); 165 | 166 | if ($this->attributes['settingable_type']) { 167 | $definition = $this->attributes['settingable_type']::getSettingDefinition($this->attributes['name']); 168 | } 169 | 170 | $caster = $this->resolveCasterClassByType($definition['cast'] ?? 'string'); 171 | } else { 172 | $caster = $this->resolveCasterClass($key); 173 | } 174 | 175 | $this->attributes = array_merge( 176 | $this->attributes, 177 | $caster instanceof CastsInboundAttributes 178 | ? [$key => $value] 179 | : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) 180 | ); 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/Models/BaseSetting.php: -------------------------------------------------------------------------------- 1 | fromArray(json_decode($value, true) ?? []); 33 | 34 | return $instance; 35 | } 36 | 37 | /** 38 | * Prepare the given value for storage. 39 | * 40 | * @param Model $model 41 | * @param string $key 42 | * @param self $value 43 | * @param array $attributes 44 | * 45 | * @return array 46 | */ 47 | public function set (Model $model, string $key, $value, array $attributes) 48 | { 49 | if ($value === null) { 50 | return [$key => null]; 51 | } 52 | 53 | if (is_array($value)) { 54 | $model = new static(); 55 | $model->fromArray($value); 56 | $value = $model; 57 | } elseif (!$value instanceof static) { 58 | throw new \Exception('The given value is not an ' . static::class . ' instance.'); 59 | } 60 | 61 | return [$key => $value->__toString()]; 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function __toString(): string 68 | { 69 | return json_encode($this->toArray()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/SettingsContainer.php: -------------------------------------------------------------------------------- 1 | checkMethods($scopeModel); 58 | $this->isScoped = true; 59 | $this->isGlobalScoped = $isGlobalScoped; 60 | $this->scopedModel = $scopeModel; 61 | $this->scopedClass = get_class($scopeModel); 62 | } 63 | } 64 | 65 | /** 66 | * @param Model $scopeModel 67 | * 68 | * @throws \Exception 69 | * 70 | * @return void 71 | */ 72 | protected function checkMethods (Model $scopeModel): void 73 | { 74 | if (!in_array( 75 | HasSettings::class, 76 | array_keys((new \ReflectionClass($scopeModel::class))->getTraits()) 77 | )) { 78 | throw new \Exception('Model ' . get_class($scopeModel) . ' have to use ' . HasSettings::class . ' trait'); 79 | } 80 | 81 | if (!is_array($scopeModel::getSettingsDefinitions())) { 82 | throw new \Exception('Model ' . get_class($scopeModel) . '::getSettingsDefinitions has to return array'); 83 | } 84 | } 85 | 86 | /** 87 | * @return array 88 | */ 89 | protected function getDefinitions (): array 90 | { 91 | if (!$this->isScoped) { 92 | return Setting::getGlobalSettingsDefinitions(); 93 | } 94 | 95 | return $this->scopedClass::getSettingsDefinitions(); 96 | } 97 | 98 | /** 99 | * @return \Illuminate\Support\Collection 100 | */ 101 | protected function getDefaults (): \Illuminate\Support\Collection 102 | { 103 | $settings = []; 104 | 105 | foreach ($this->getDefinitions() as $definition) { 106 | $settings[] = new Setting([ 107 | 'settingable_id' => null, 108 | 'settingable_type' => $this->isScoped ? $this->scopedClass : null, 109 | 'name' => $definition['name'], 110 | 'value' => $definition['default'] ?? null, 111 | ]); 112 | } 113 | 114 | return collect($settings); 115 | } 116 | 117 | /** 118 | * @param \BackedEnum|string $name 119 | * 120 | * @return string 121 | */ 122 | protected function castSettingName (\BackedEnum|string $name): string 123 | { 124 | return $name instanceof \BackedEnum 125 | ? $name->value 126 | : $name; 127 | } 128 | 129 | /** 130 | * @param Collection $collection 131 | * 132 | * @return Collection 133 | */ 134 | protected function appendDefaults (Collection $collection): Collection 135 | { 136 | foreach ($this->getDefaults() as $default) { 137 | if ($collection->where('name', $this->castSettingName($default->name))->first()) { 138 | continue; 139 | } 140 | 141 | $collection->add($default); 142 | } 143 | 144 | return $collection; 145 | } 146 | 147 | /** 148 | * @return void 149 | */ 150 | public function clearCache (): void 151 | { 152 | $this->cachedSettings = null; 153 | $this->cachedSettingsWithDefaults = null; 154 | } 155 | 156 | /** 157 | * @return void 158 | */ 159 | protected function clearCachedDefaults (): void 160 | { 161 | $this->cachedSettingsWithDefaults = null; 162 | } 163 | 164 | /** 165 | * @param \BackedEnum|string $name 166 | * 167 | * @return bool 168 | */ 169 | public function isValidSettingName (\BackedEnum|string $name): bool 170 | { 171 | foreach ($this->getDefinitions() as $definition) { 172 | if ($this->castSettingName($definition['name']) === $this->castSettingName($name)) { 173 | return true; 174 | } 175 | } 176 | 177 | return false; 178 | } 179 | 180 | /** 181 | * @param \BackedEnum|string $name 182 | * @param mixed $value 183 | * 184 | * @return bool 185 | */ 186 | public function isValidSettingValue (\BackedEnum|string $name, mixed $value): bool 187 | { 188 | if ($value === null) { 189 | foreach ($this->getDefinitions() as $definition) { 190 | if ($this->castSettingName($definition['name']) === $this->castSettingName($name)) { 191 | if (isset($definition['is_nullable']) && $definition['is_nullable'] === true) { 192 | return true; 193 | } 194 | 195 | return false; 196 | } 197 | } 198 | } 199 | 200 | $options = $this->getSettingOptions($name); 201 | 202 | if (!empty($options)) { 203 | $options = array_map(function ($option) { 204 | return $option instanceof \BackedEnum 205 | ? $option->value 206 | : $option; 207 | }, $options); 208 | 209 | $value = $value instanceof \BackedEnum 210 | ? $value->value 211 | : $value; 212 | 213 | return in_array($value, $options); 214 | } 215 | 216 | return true; 217 | } 218 | 219 | /** 220 | * @param ?Model $model 221 | * 222 | * @return bool 223 | */ 224 | public function isScopedTo (?Model $model = null): bool 225 | { 226 | return $model && $this?->scopedModel === $model; 227 | } 228 | 229 | /** 230 | * @param \BackedEnum|string $name 231 | * 232 | * @return array 233 | */ 234 | public function getSettingOptions (\BackedEnum|string $name): array 235 | { 236 | foreach ($this->getDefinitions() as $definition) { 237 | if ($this->castSettingName($definition['name']) === $this->castSettingName($name)) { 238 | if (!empty($definition['enum'])) { 239 | return array_map(function ($item) use ($definition) { 240 | if ((new \ReflectionEnum($definition['enum']))->isBacked()) { 241 | return $item->value; 242 | } else { 243 | return $item->name; 244 | } 245 | }, $definition['enum']::cases()); 246 | } 247 | 248 | if (isset($definition['options'])) { 249 | return $definition['options']; 250 | } 251 | } 252 | } 253 | 254 | return []; 255 | } 256 | 257 | /** 258 | * @param \BackedEnum|string $name 259 | * 260 | * @throws \Exception 261 | * 262 | * @return Setting|null 263 | */ 264 | public function setting (\BackedEnum|string $name): ?Setting 265 | { 266 | if ($name instanceof \BackedEnum) { 267 | $reflection = new \ReflectionEnum($name); 268 | 269 | if ((string) $reflection->getBackingType() !== 'string') { 270 | throw new \Exception('Only BackedEnum of string type can be used as setting name'); 271 | } 272 | } 273 | 274 | if (!$this->isValidSettingName($name)) { 275 | if ($this->isScoped) { 276 | throw new \Exception($this->scopedClass . ' setting definition does not exists for "' . $name . '"'); 277 | } else { 278 | throw new \Exception('Global setting definition does not exists for "' . $name . '"'); 279 | } 280 | } 281 | 282 | if ($this->cacheSettings) { 283 | $this->all(); 284 | 285 | return $this->cachedSettings 286 | ->where('name', $this->castSettingName($name)) 287 | ->first(); 288 | } 289 | 290 | if ($this->isScoped) { 291 | return Setting::where('settingable_id', $this->scopedModel->id) 292 | ->where('settingable_type', $this->scopedClass) 293 | ->where('name', $this->castSettingName($name)) 294 | ->first(); 295 | } 296 | 297 | return Setting::whereNull('settingable_id') 298 | ->whereNull('settingable_type') 299 | ->where('name', $this->castSettingName($name)) 300 | ->first(); 301 | } 302 | 303 | /** 304 | * @param \BackedEnum|string $name 305 | * @param mixed|null $default 306 | * @param bool $returnDefaultCast 307 | * 308 | * @throws \Exception 309 | * 310 | * @return mixed 311 | */ 312 | public function get (\BackedEnum|string $name, mixed $default = null, bool $returnDefaultCast = true): mixed 313 | { 314 | $setting = $this->setting($name); 315 | $settingDefinition = []; 316 | 317 | foreach ($this->getDefinitions() as $definition) { 318 | if ($this->castSettingName($definition['name']) === $this->castSettingName($name)) { 319 | $settingDefinition = $definition; 320 | 321 | break; 322 | } 323 | } 324 | 325 | if ($setting?->id === null) { 326 | if ($returnDefaultCast 327 | && !empty($settingDefinition['cast']) 328 | && is_subclass_of($settingDefinition['cast'], BaseSetting::class) 329 | ) { 330 | $setting = new Setting([ 331 | 'settingable_id' => null, 332 | 'settingable_type' => $this->isScoped ? $this->scopedClass : null, 333 | 'name' => $settingDefinition['name'], 334 | 'value' => $settingDefinition['default'] ?? null, 335 | ]); 336 | 337 | return $setting->value; 338 | } 339 | 340 | return $default ?: $settingDefinition['default'] ?? null; 341 | } 342 | 343 | return $setting->value; 344 | } 345 | 346 | /** 347 | * @param \BackedEnum|string $name 348 | * @param mixed $value 349 | * 350 | * @throws \Exception 351 | * 352 | * @return Setting 353 | */ 354 | public function set (\BackedEnum|string $name, mixed $value): Setting 355 | { 356 | $setting = $this->setting($name); 357 | 358 | if (!$this->isValidSettingValue($name, $value)) { 359 | throw new \Exception('Invalid setting value for "' . $name . '"'); 360 | } 361 | 362 | if ($setting) { 363 | $setting->value = $value; 364 | $setting->save(); 365 | } else { 366 | $setting = Setting::create([ 367 | 'settingable_id' => $this->isScoped ? $this->scopedModel->id : null, 368 | 'settingable_type' => $this->isScoped ? $this->scopedClass : null, 369 | 'name' => $name, 370 | 'value' => $value, 371 | ]); 372 | $setting = Setting::find($setting->id); 373 | 374 | if ($this->cacheSettings && $this->cachedSettings) { 375 | $this->cachedSettings->add($setting); 376 | $this->clearCachedDefaults(); 377 | } 378 | } 379 | 380 | return $setting; 381 | } 382 | 383 | /** 384 | * @return Collection 385 | */ 386 | public function all (): Collection 387 | { 388 | if ($this->cacheSettings && $this->cachedSettings) { 389 | return $this->cachedSettings; 390 | } 391 | 392 | if ($this->isScoped) { 393 | $this->cachedSettings = Setting::where('settingable_id', $this->scopedModel->id) 394 | ->where('settingable_type', $this->scopedClass) 395 | ->get(); 396 | 397 | return $this->cachedSettings; 398 | } 399 | 400 | $this->cachedSettings = Setting::whereNull('settingable_id') 401 | ->whereNull('settingable_type') 402 | ->get(); 403 | 404 | return $this->cachedSettings; 405 | } 406 | 407 | /** 408 | * @return Collection 409 | */ 410 | public function allWithDefaults (): Collection 411 | { 412 | if ($this->cacheSettings && $this->cachedSettingsWithDefaults) { 413 | return $this->cachedSettingsWithDefaults; 414 | } 415 | 416 | $this->cachedSettingsWithDefaults = $this->appendDefaults($this->all()); 417 | 418 | return $this->cachedSettingsWithDefaults; 419 | } 420 | 421 | /** 422 | * Do not use caching for settings 423 | * 424 | * @return $this 425 | */ 426 | public function noCache (): SettingsContainer 427 | { 428 | $this->cacheSettings = false; 429 | 430 | return $this; 431 | } 432 | 433 | /** 434 | * Use caching for settings 435 | * 436 | * @return $this 437 | */ 438 | public function cache (): SettingsContainer 439 | { 440 | $this->cacheSettings = true; 441 | 442 | return $this; 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /src/SettingsServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton('settings', function ($app) { 19 | return new SettingsAccessor(Auth::user()); 20 | }); 21 | } 22 | 23 | /** 24 | * Bootstrap any application services. 25 | * 26 | * @return void 27 | */ 28 | public function boot() 29 | { 30 | $this->offerPublishing(); 31 | } 32 | 33 | /** 34 | * Setup the resource publishing groups for settings 35 | * 36 | * @return void 37 | */ 38 | protected function offerPublishing() 39 | { 40 | if ($this->app->runningInConsole()) { 41 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 42 | 43 | $this->publishes([ 44 | __DIR__.'/../database/migrations' => database_path('migrations'), 45 | ], 'settings-migrations'); 46 | 47 | $this->publishes([ 48 | __DIR__.'/../stubs/Setting.stub' => app_path('Models/Setting.php'), 49 | ], 'settings-model'); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/Traits/CastsToType.php: -------------------------------------------------------------------------------- 1 | isCustomDateTimeCast($type)) { 20 | $convertedCastType = 'custom_datetime'; 21 | } elseif ($this->isImmutableCustomDateTimeCast($type)) { 22 | $convertedCastType = 'immutable_custom_datetime'; 23 | } elseif ($this->isDecimalCast($type)) { 24 | $convertedCastType = 'decimal'; 25 | } else { 26 | $convertedCastType = trim(strtolower($type)); 27 | } 28 | 29 | return $convertedCastType; 30 | } 31 | 32 | /** 33 | * @param string $castType 34 | * 35 | * @return bool 36 | */ 37 | protected function isEnumCastableByType (string $castType): bool 38 | { 39 | if (in_array($castType, static::$primitiveCastTypes)) { 40 | return false; 41 | } 42 | 43 | if (function_exists('enum_exists') && enum_exists($castType)) { 44 | return true; 45 | } 46 | 47 | return false; 48 | } 49 | 50 | /** 51 | * @param string $castType 52 | * @param mixed $value 53 | * 54 | * @return \BackedEnum|mixed|\UnitEnum|void 55 | */ 56 | protected function getEnumCastableAttributeValueByType (string $castType, mixed $value) 57 | { 58 | if (is_null($value)) { 59 | return; 60 | } 61 | 62 | if ($value instanceof $castType) { 63 | return $value; 64 | } 65 | 66 | try { 67 | return $this->getEnumCaseFromValue($castType, $value); 68 | } catch (\Error $e) { 69 | return null; 70 | } 71 | } 72 | 73 | /** 74 | * @param string $type 75 | * 76 | * @return bool 77 | */ 78 | protected function isClassCastableByType (string $type) 79 | { 80 | $castType = $this->parseCasterClass($type); 81 | 82 | if (in_array($castType, static::$primitiveCastTypes)) { 83 | return false; 84 | } 85 | 86 | if (class_exists($castType)) { 87 | return true; 88 | } 89 | 90 | throw new InvalidCastException($this->getModel(), 'value', $castType); 91 | } 92 | 93 | /** 94 | * @param string $castType 95 | * 96 | * @return mixed 97 | */ 98 | protected function resolveCasterClassByType (string $castType): mixed 99 | { 100 | $arguments = []; 101 | 102 | if (is_string($castType) && str_contains($castType, ':')) { 103 | $segments = explode(':', $castType, 2); 104 | 105 | $castType = $segments[0]; 106 | $arguments = explode(',', $segments[1]); 107 | } 108 | 109 | if (is_subclass_of($castType, Castable::class)) { 110 | $castType = $castType::castUsing($arguments); 111 | } 112 | 113 | if (is_object($castType)) { 114 | return $castType; 115 | } 116 | 117 | return new $castType(...$arguments); 118 | } 119 | 120 | /** 121 | * @param string $type 122 | * @param mixed $value 123 | * 124 | * @return mixed 125 | */ 126 | protected function getClassCastableAttributeValueByType (string $type, mixed $value): mixed 127 | { 128 | $caster = $this->resolveCasterClassByType($type); 129 | 130 | $value = $caster instanceof CastsInboundAttributes 131 | ? $value 132 | : $caster->get($this, 'value', $value, $this->attributes); 133 | 134 | return $value; 135 | } 136 | 137 | /** 138 | * @param string $type 139 | * @param string $key 140 | * @param mixed $value 141 | * 142 | * @return void 143 | */ 144 | protected function setEnumCastableAttributeByType(string $type, string $key, mixed $value): void 145 | { 146 | $enumClass = $type; 147 | 148 | if (!isset($value)) { 149 | $this->attributes[$key] = null; 150 | } elseif (is_object($value)) { 151 | $this->attributes[$key] = $this->getStorableEnumValue($type, $value); 152 | } else { 153 | $this->attributes[$key] = $this->getStorableEnumValue( 154 | $type, 155 | $this->getEnumCaseFromValue($enumClass, $value) 156 | ); 157 | } 158 | } 159 | 160 | /** 161 | * @param string $type 162 | * @param string $key 163 | * @param mixed $value 164 | * 165 | * @return void 166 | */ 167 | protected function setClassCastableAttributeByType (string $type, string $key, mixed $value): void 168 | { 169 | $caster = $this->resolveCasterClassByType($type); 170 | 171 | $this->attributes = array_merge( 172 | $this->attributes, 173 | $this->normalizeCastClassResponse($key, $caster->set( 174 | $this, $key, $value, $this->attributes 175 | )) 176 | ); 177 | 178 | if ($caster instanceof CastsInboundAttributes || ! is_object($value)) { 179 | unset($this->classCastCache[$key]); 180 | } else { 181 | $this->classCastCache[$key] = $value; 182 | } 183 | } 184 | 185 | /** 186 | * @param string $type 187 | * 188 | * @return bool 189 | */ 190 | protected function isJsonCastableByType (string $type): bool 191 | { 192 | return in_array($type, ['array', 'json', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object'], true); 193 | } 194 | 195 | /** 196 | * @param string $type 197 | * @param mixed $value 198 | * 199 | * @return mixed 200 | */ 201 | protected function castToType (string $type, mixed $value): mixed 202 | { 203 | $castType = $this->getCastTypeFromType($type); 204 | 205 | if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) { 206 | return $value; 207 | } 208 | 209 | switch ($castType) { 210 | case 'int': 211 | case 'integer': 212 | return (int) $value; 213 | case 'real': 214 | case 'float': 215 | case 'double': 216 | return $this->fromFloat($value); 217 | case 'decimal': 218 | return $this->asDecimal($value, explode(':', $type, 2)[1]); 219 | case 'string': 220 | return (string) $value; 221 | case 'bool': 222 | case 'boolean': 223 | return (bool) $value; 224 | case 'object': 225 | return $this->fromJson($value, true); 226 | case 'array': 227 | case 'json': 228 | return $this->fromJson($value); 229 | case 'collection': 230 | return new BaseCollection($this->fromJson($value)); 231 | case 'date': 232 | return $this->asDate($value); 233 | case 'datetime': 234 | case 'custom_datetime': 235 | return $this->asDateTime($value); 236 | case 'immutable_date': 237 | return $this->asDate($value)->toImmutable(); 238 | case 'immutable_custom_datetime': 239 | case 'immutable_datetime': 240 | return $this->asDateTime($value)->toImmutable(); 241 | case 'timestamp': 242 | return $this->asTimestamp($value); 243 | } 244 | 245 | if ($this->isEnumCastableByType($type)) { 246 | return $this->getEnumCastableAttributeValueByType($type, $value); 247 | } 248 | 249 | if ($this->isClassCastableByType($type)) { 250 | return $this->getClassCastableAttributeValueByType($type, $value); 251 | } 252 | 253 | return $value; 254 | } 255 | 256 | /** 257 | * @param string $type 258 | * @param string $key 259 | * @param mixed $value 260 | * 261 | * @return mixed 262 | */ 263 | public function setAttributeByType (string $type, string $key, mixed $value): mixed 264 | { 265 | if ($this->isEnumCastableByType($type)) { 266 | $this->setEnumCastableAttributeByType($type, $key, $value); 267 | 268 | return $this; 269 | } 270 | 271 | if ($this->isClassCastableByType($type)) { 272 | $this->setClassCastableAttributeByType($type, $key, $value); 273 | 274 | return $this; 275 | } 276 | 277 | if (!is_null($value) && $this->isJsonCastableByType($type)) { 278 | $value = $this->castAttributeAsJson($key, $value); 279 | } 280 | 281 | if (str_contains($key, '->')) { 282 | return $this->fillJsonAttribute($key, $value); 283 | } 284 | 285 | $this->attributes[$key] = $value; 286 | 287 | return $this; 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/Traits/HasSettings.php: -------------------------------------------------------------------------------- 1 | morphMany(Setting::class, 'settingable'); 28 | } 29 | 30 | /** 31 | * @return SettingsContainer 32 | */ 33 | public function getSettingsAttribute (): SettingsContainer 34 | { 35 | return Settings::scope($this); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Traits/HasSettingsDefinitions.php: -------------------------------------------------------------------------------- 1 | value 17 | : $name; 18 | 19 | foreach ($definitions as $definition) { 20 | $definitionName = $definition['name'] instanceof \BackedEnum 21 | ? $definition['name']->value 22 | : $definition['name']; 23 | 24 | if ($definitionName === $stringName) { 25 | return $definition; 26 | } 27 | } 28 | 29 | return null; 30 | } 31 | 32 | /** 33 | * @return array 34 | */ 35 | abstract public static function getSettingsDefinitions(): array; 36 | } 37 | -------------------------------------------------------------------------------- /stubs/Setting.stub: -------------------------------------------------------------------------------- 1 | 'example_setting', 22 | 'default' => 'default_value', 23 | 'options' => [ 24 | 'default_value_2', 25 | 'default_value', 26 | ], 27 | ], 28 | [ 29 | 'name' => 'example_setting_2', 30 | 'cast' => 'bool', 31 | 'default' => false, 32 | ], 33 | ]; 34 | } 35 | } 36 | --------------------------------------------------------------------------------