This Laravel package aims to store and manage user settings/preferences in a simple and scalable manner.
- Features
- Installation
- Usage
- Working with preferences
- Casting
- Rules
- Policies
- Routing
- Security
- Upgrade from v1
- Test
- Contributing
- Security Vulnerabilities
- Credits
- License
- Support target
- Type safe Casting
- Validation & Authorization
- Extensible (Create your own Validation Rules and Casts)
- Enum support
- Custom Api routes
- work with preferences from a GUI or in addition to backend functionalities
-
Event System -> #13
-
Api Response customization -> #14
-
QoL Helpers functions
-
Caching
-
Blade Directives
-
Additional suggestions are welcome. (check out Contributing)
You can install the package via composer:
composer require matteoc99/laravel-preference
Important
consider installing also graham-campbell/security-core:^4.0
to take advantage of xss cleaning.
see Security for more information
You can publish the config file with:
php artisan vendor:publish --tag="laravel-preference-config"
'db' => [
'connection' => null, //string: the connection name to use
'preferences_table_name' => 'preferences',
'user_preferences_table_name' => 'user_preferences',
],
'xss_cleaning' => true, // clean user input for cross site scripting attacks
'routes' => [
'enabled' => false, // set true to register routes, more on that later
'middlewares' => [
'auth', // general middleware
'user'=> 'verified', // optional, scoped middleware
'user.general'=> 'verified' // optional, scoped & grouped middleware
],
'prefix' => 'preferences',
'groups' => [
//enum class list of preferences
'general'=>General::class
],
'scopes'=> [
// as many preferenceable models as you want
'user' => \Illuminate\Auth\Authenticatable::class
]
]
Note
Consider changing the base table names before running the migrations, if needed
Run the migrations with:
php artisan migrate
Each preference has at least a name and a caster. Names are stored in one or more enums and are the unique identifier for that preference
For additional validation you can add you custom Rule object.
For additional security you can add Policies
Organize them in one or more string backed enum.
Note
while it does not need to be string backed, its way more developer friendly. Especially when interacting over the APi
Each enum gets scoped and does not conflict with other enums with the same case
e.g.
use Matteoc99\LaravelPreference\Contracts\PreferenceGroup;
enum Preferences :string implements PreferenceGroup
{
case LANGUAGE="language";
case QUALITY="quality";
case CONFIG="configuration";
}
enum General :string implements PreferenceGroup
{
case LANGUAGE="language";
case THEME="theme";
}
use Matteoc99\LaravelPreference\Enums\Cast;
public function up(): void
{
PreferenceBuilder::init(Preferences::LANGUAGE)
->withDefaultValue("en")
->withRule(new InRule("en", "it", "de"))
->create();
// Or
PreferenceBuilder::init(Preferences::LANGUAGE)->create()
// different enums with the same value do not conflict
PreferenceBuilder::init(General::LANGUAGE)->create()
// update
PreferenceBuilder::init(Preferences::LANGUAGE)
->withRule(new InRule("en", "it", "de"))
->updateOrCreate()
// or with casting
PreferenceBuilder::init(Preferences::LANGUAGE, Cast::ENUM)
->withDefaultValue(Language::EN)
->create()
// nullable support
PreferenceBuilder::init(Preferences::LANGUAGE, Cast::ENUM)
->withDefaultValue(null)
->nullable()
->create()
}
public function down(): void
{
PreferenceBuilder::delete(Preferences::LANGUAGE);
}
use Illuminate\Database\Migrations\Migration;use Matteoc99\LaravelPreference\Enums\Cast;use Matteoc99\LaravelPreference\Factory\PreferenceBuilder;use Matteoc99\LaravelPreference\Rules\InRule;
return new class extends Migration {
public function up(): void
{
PreferenceBuilder::initBulk($this->preferences(),
true // nullable for the whole Bulk
);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
PreferenceBuilder::deleteBulk($this->preferences());
}
/**
* Reverse the migrations.
*/
public function preferences(): array
{
return [
['name' => Preferences::LANGUAGE, 'cast' => Cast::STRING, 'default_value' => 'en', 'rule' => new InRule("en", "it", "de")],
['name' => Preferences::THEME, 'cast' => Cast::STRING, 'default_value' => 'light'],
['name' => Preferences::CONFIGURATION, 'cast' => Cast::ARRAY],
['name' => Preferences::CONFIGURATION,
'nullable' => true // or nullable for only one configuration
],
// or an array of initialized single-mode builders
PreferenceBuilder::init(Preferences::LANGUAGE)->withRule(new InRule("en", "it", "de")),
PreferenceBuilder::init(Preferences::THEME)->withRule(new InRule("light", "dark"))
//mixing both in one array is also possible
];
}
};
Check all methods available to build a Preference
This table includes a complete list of all features available, when building a preference.
Single-Mode | Bulk-Mode (array-keys) | Constrains | Description |
---|---|---|---|
init(>name<,>cast<) | ["name"=> >name<] |
>name< = instanceof PreferenceGroup | Unique identifier for the preference |
init(>name<,>cast<) | ["cast"=> >cast<] |
>cast< = instanceof CastableEnum | Caster to translate the value between all different scenarios. Currently: Api-calls as well as saving to and retrieving fron the DB |
nullable(>nullable<) | ["nullable"=> >nullable<] |
>nullable< = bool | Whether the default value can be null and if the preference can be set to null |
withDefaultValue(>default_value<) | ["default_value"=> >default_value<] |
>default_value< = mixed, but must comply with the cast & validationRule | Initial value for this preference |
withDescription(>description<) | ["description"=> >description<] |
>description< = string | Legacy code from v1.x has no actual use as of now |
withPolicy(>policy<) | ["policy"=> >policy<] |
>policy< = instanceof PreferencePolicy | Authorize actions such as update/delete etc. on certain preferences. |
withRule(>rule<) | ["rule"=> >rule<] |
>rule< = instanceof ValidationRule | Additional validation Rule, to validate values before setting them |
setAllowedClasses(>allowed_values<) | ["allowed_values"=> >allowed_values<] |
>allowed_values< = array of string classes. For non Primitive Casts only | Current use-cases: - restrict classes of enum or object that can be set to this preference - reconstruct the original class when sending data via api. |
Optionally, pass the default value as a second parameter
// quickly build a nullable Array preference
PreferenceBuilder::buildArray(VideoPreferences::CONFIG);
PreferenceBuilder::buildString(VideoPreferences::LANGUAGE);
Two things are needed:
HasPreferences
trait to access the helper functionsPreferenceableModel
Interface to have access to the implementation- in particular to
isUserAuthorized
- in particular to
Guard function to validate if the currently logged in (if any) user has access to this model Signature:
- $user the logged in user
- PolicyAction enum: the action the user wants to perform index/get/update/delete
Note
this is just the bare minimum regarding Authorization.
For more fine-grained authorization checks refer to Policies
use Matteoc99\LaravelPreference\Contracts\PreferenceableModel;
use Matteoc99\LaravelPreference\Enums\PolicyAction;
use Matteoc99\LaravelPreference\Traits\HasPreferences;
class User extends \Illuminate\Foundation\Auth\User implements PreferenceableModel
{
use HasPreferences;
protected $fillable = ['email'];
public function isUserAuthorized(?Authenticatable $user, PolicyAction $action): bool
{
return $user?->id == $this->id ;
}
}
$user->setPreference(Preferences::LANGUAGE,"de");
$user->getPreference(Preferences::LANGUAGE); // 'de' as string
$user->setPreference(Preferences::LANGUAGE,"fr");
// ValidationException because of the rule: ->withRule(new InRule("en","it","de"))
$user->setPreference(Preferences::LANGUAGE,2);
// ValidationException because of the cast: Cast::STRING
$user->removePreference(Preferences::LANGUAGE);
$user->getPreference(Preferences::LANGUAGE); // 'en' as string
// get all of type Preferences,
$user->getPreferences(Preferences::class)
// or of type general
$user->getPreferences(General::class)
//or all
$user->getPreferences(): Collection of UserPreferences
// removes all preferences set for tht user
$user->removeAllPreferences();
Set the cast when creating a Preference
Note
a cast has 3 main jobs
- Basic validation
- Casting from and to the database
- Preparing Api Responses
PreferenceBuilder::init(Preferences::LANGUAGE, Cast::ENUM)
Cast | Explanation |
---|---|
INT | Converts and Validates a value to be an integer. |
FLOAT | Converts and Validates a value to be a floating-point number. |
STRING | Converts and Validates a value to be a string. |
BOOL | Converts and Validates a value to be a boolean (regards non-empty as true ). |
ARRAY | Converts and Validates a value to be an array. |
BACKED_ENUM | Ensures the value is a BackedEnum type. Useful for enums with underlying values. |
ENUM | Ensures the value is a UnitEnum type. Useful for enums without underlying values. |
OBJECT | Ensures that the value is an object. |
NONE | No casting is performed. Returns the value as-is. |
Date-Casts | Explanation |
---|---|
Converts a value using Carbon::parse, and always return a Carbon instance. Validation is Cast-Specific |
|
DATE | sets the time to be 00:00 . |
TIME | Always uses the current date, setting only the time |
DATETIME | with both date and time(optionally). |
TIMESTAMP | allows a string/int timestamp or a carbon instance |
Implement CastableEnum
Important
The custom caster needs to be a string backed enum
use Illuminate\Contracts\Validation\ValidationRule;
use Matteoc99\LaravelPreference\Contracts\CastableEnum;
enum MyCast: string implements CastableEnum
{
case TIMEZONE = 'tz';
public function validation(): ValidationRule|array|string|null
{
return match ($this) {
self::TIMEZONE => 'timezone:all',
};
}
public function castFromString(string $value): mixed
{
return match ($this) {
self::TIMEZONE => $value,
};
}
public function castToString(mixed $value): string
{
return match ($this) {
self::TIMEZONE => (string)$value,
};
}
public function castToDto(mixed $value): array
{
return ['value' => $value];
}
}
PreferenceBuilder::init(Preferences::TIMEZONE, MyCast::TIMEZONE)->create();
Additional validation, which can be way more complex than provided by the Cast
PreferenceBuilder::init(General::VOLUME, Cast::INT)
->withRule(new LowerThanRule(5))
->updateOrCreate()
PreferenceBuilder::initBulk([
'name' => General::VOLUME,
'cast' => Cast::INT
'rule' => new LowerThanRule(5)
]);
Rule | Example | Description |
---|---|---|
AndRule | new AndRule(new BetweenRule(2.4, 5.5), new LowerThanRule(5)) |
Expects n ValidationRule, ensures all pass |
OrRule | new OrRule(new BetweenRule(2.4, 5.5), new LowerThanRule(5)) |
Expects n ValidationRule, ensures at least one passes |
LaravelRule | new LaravelRule("required|numeric") |
Expects a string, containing a Laravel Validation Rule |
BetweenRule | new BetweenRule(2.4, 5.5) |
For INT and FLOAT, check that the value is between min and max |
InRule | new InRule("it","en","de") |
Expects the value to be validated to be in that equal to one of the n params |
InstanceOfRule | new InstanceOfRule(Theme::class) |
For non primitive casts, checks the instance of the value's class to validate. Tip: goes along well with the OrRule |
IsRule | new IsRule(Type::ITERABLE) |
Expects a Matteoc99\LaravelPreference\Enums\Type Enum. Checks e.g. if the value is iterable |
LowerThanRule | new LowerThanRule(5) |
For INT and FLOAT, check that the value to be validated is less than the one passed in the constructor |
Implement Laravel's ValidationRule
class MyRule implements ValidationRule
{
protected array $data;
public function __construct(...$data)
{
$this->data = $data;
}
public function message()
{
return sprintf("Wrong Timezone, one of: %s expected", implode(", ",$this->data));
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if(!Str::startsWith($value, $this->data)){
$fail($this->message());
}
}
}
PreferenceBuilder::init("timezone",MyCast::TIMEZONE)
->withRule(new MyRule("Europe","Asia"))
Each preference can have a Policy, should isUserAuthorized not be enough for your usecase
Implement PreferencePolicy
and the 4 methods defined by the contract
parameter | description |
---|---|
Authenticatable $user | the currently logged in user, if any |
PreferenceableModel $model | the model on which you are trying to modify the preference |
PreferenceGroup $preference | the preference enum in question |
PreferenceBuilder::init(Preferences::LANGUAGE)
->withPolicy(new MyPolicy())
->updateOrCreate()
PreferenceBuilder::initBulk([
'name' => Preferences::LANGUAGE,
'policy' => new MyPolicy()
]);
Off by default, enable it in the config
Warning
(Current) limitation: it's not possible to set object casts via API
'Scope': the PreferenceableModel
Model
'Group': the PreferenceGroup
enum
Routes then get transformed to:
Action | URI | Description |
---|---|---|
GET | /{prefix}/{scope}/{scope_id}/{group} | Retrieves all preferences for a given scope and group. |
GET | /{prefix}/{scope}/{scope_id}/{group}/{preference} | Retrieves a specific preference within the scope and group. |
PUT/PATCH | /{prefix}/{scope}/{scope_id}/{group}/{preference} | Updates a specific preference within the scope and group. |
DELETE | /{prefix}/{scope}/{scope_id}/{group}/{preference} | Deletes a specific preference within the scope and group. |
which can all be accessed via the route name: {prefix}.{scope}.{group}.{index/get/update/delete}
scope_id
: The unique identifier of the scope (e.g., a user's ID).
preference
: The value of the specific preference enum (e.g., General::LANGUAGE->value).
group
: A mapping of group names to their corresponding Enum classes. See config below
scope
: A mapping of scope names to their corresponding Eloquent model. See config below
'routes' => [
'enabled' => true,
'middlewares' => [
'auth',
'user'=> 'verified'
],
'prefix' => 'custom_prefix',
'groups' => [
'general'=>\Matteoc99\LaravelPreference\Tests\TestSubjects\Enums\General::class
'video'=>\Matteoc99\LaravelPreference\Tests\TestSubjects\Enums\VideoPreferences::class
],
'scopes'=> [
'user' => \Matteoc99\LaravelPreference\Tests\TestSubjects\Models\User::class
]
]
will result in the following route names:
- custom_prefix.user.general.index
- custom_prefix.user.general.get
- custom_prefix.user.general.update
- custom_prefix.user.general.delete
- custom_prefix.user.video.index
- custom_prefix.user.video.get
- custom_prefix.user.video.update
- custom_prefix.user.video.delete
Note
Examples are with scope user
and group general
- Route Name: custom_prefix.user.general.index
- Url params:
scope_id
- Equivalent to:
$user->getPreferences(General::class)
- Http method: GET
- Endpoint: 'https://your.domain/custom_prefix/user/{scope_id}/general'
- Route Name: custom_prefix.user.general.get
- Url params:
scope_id
,preference
- Equivalent to:
$user->getPreference(General::{preference})
- Http method: GET
- Endpoint: https://your.domain/custom_prefix/user/{scope_id}/general/{preference}
- Route Name: custom_prefix.user.general.update
- Url params:
scope_id
,preference
- Equivalent to:
$user->setPreference(General::{preference}, >value<)
- Http method: PATCH/PUT
- Endpoint: https://your.domain/custom_prefix/user/{scope_id}/general/{preference}
- Payload:
{ "value": >value< }
When creating your enum preference, add setAllowedClasses
containing the possible enums to reconstruct the value
Caution
if multiple cases are shared between enums, the first match is taken
then, when sending the value it varies:
- BackedEnum: send the value or the case
- UnitEnum: send the case
Example:
enum Theme
{
case LIGHT;
case DARK;
}
curl -X PATCH 'https://your.domain/custom_prefix/user/{scope_id}/general/{preference}' \
-d '{"value": "DARK"}'
- Route Name: (custom_prefix.user.general.delete)
- Url params:
scope_id
,preference
- Equivalent to:
$user->removePreference(General::{preference})
- Http method: DELETE
- Endpoint: https://your.domain/custom_prefix/user/{scope_id}/general/{preference}
set global or context specific middlewares in the config file
'middlewares' => [
'web', // required for Auth::user() and policies
'auth', //no key => general middleware which gets applied to all routes
'user'=> 'verified', // scoped middleware only for user routes should you have other preferencable models
'user.general'=> 'verified' // scoped & grouped middleware only for a specific model + enum
],
Caution
known Issues: without the web middleware, you won't have access to the user via the Auth facade since it's set by the middleware. Looking into an alternative
XSS cleaning is only performed on user facing api calls.
this can be disabled, if not required, with the config: user_preference.xss_cleaning
When setting preferences directly via setPreference
this cleaning step is assumed to have already been performed, if necessary.
Consider installing Security-Core to make use of this feature
- implement
PreferenceGroup
in your Preference enums - implement
PreferenceableModel
in you all Models that want to use preferences - Switch from
HasValidation
toValidationRule
- Signature changes on the trait: group got removed and name now requires a
PreferenceGroup
- Builder: setting group got removed and name now expects a
PreferenceGroup
enum DataRule
has been removed, add a constructor to get you own, tailored, params- database serialization incompatibilities will require you to rerun your Preference migrations
- single mode: make sure to use
updateOrCreate
, e.gPreferenceBuilder::init(VideoPreferences::QUALITY)->updateOrCreate();
- bulk mode: initBulk as usual, as it works with upsert
- single mode: make sure to use
composer test
composer coverage
check out act install it via gh
then run: composer pipeline
See Contributing for details.
Please review our security policy on how to report security vulnerabilities.
- matteoc99
- Joel Brown for this awesome starting point and initial inspiration
The MIT License (MIT). Please check the License File for more information.
Package Version | Laravel Version | Maintained |
---|---|---|
1.x | 10 | ❌ |
2.x | 10 & 11 | ✅ |