matteoc99/laravel-preference

Laravel package that aims to store and manage user settings/preferences in a simple and scalable manner

v2.1.2 2024-05-05 15:45 UTC

README

Latest Version on Packagist Total Downloads Tests codecov

This Laravel package aims to store and manage user settings/preferences in a simple and scalable manner.

Table of Contents

Features

  • 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

Roadmap

  • Additional inbuilt Custom Rules -> v2.x
  • Allow array of preferenceBuilders in initBuk -> v2.1.x
  • Readme restructuring -> v2.1.x
  • QoL Helpers functions (removeAll, quickInit, etc) -> v2.1.x
  • Event System -> v2.2
  • Api Response customization -> v2.3
  • Caching
  • Suggestions are welcome

Installation

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

Configuration

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

Usage

Concepts

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

Define your preferences

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";
}

Create a Preference

single mode

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);
}

Bulk mode

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
       ];
    }
};

Preference Building

Check all methods available to build a Preference

Available Methods

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.

Available helper functions

Optionally, pass the default value as a second parameter

        // quickly build a nullable Array preference
        PreferenceBuilder::buildArray(VideoPreferences::CONFIG);

        PreferenceBuilder::buildString(VideoPreferences::LANGUAGE);

Working with preferences

Two things are needed:

  • HasPreferences trait to access the helper functions
  • PreferenceableModel Interface to have access to the implementation
    • in particular to isUserAuthorized

isUserAuthorized

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

Example implementation:

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 ;
    }
}

Examples

    $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();
    

Casting

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

Example:

PreferenceBuilder::init(Preferences::LANGUAGE, Cast::ENUM)

Available Casts

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

Custom Caster

Implement CastableEnum

Important

The custom caster needs to be a string backed enum

Example:

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();

Rules

Additional validation, which can be way more complex than provided by the Cast

Adding Rules

     PreferenceBuilder::init(General::VOLUME, Cast::INT)
        ->withRule(new LowerThanRule(5))
        ->updateOrCreate()


    PreferenceBuilder::initBulk([
        'name' => General::VOLUME,
        'cast' => Cast::INT
        'rule' => new LowerThanRule(5)
     ]);

Available Rules

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

Custom Rules

Implement Laravel's ValidationRule

Example:

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"))

Policies

Each preference can have a Policy, should isUserAuthorized not be enough for your usecase

Creating policies

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

Adding policies

    PreferenceBuilder::init(Preferences::LANGUAGE)
        ->withPolicy(new MyPolicy())
        ->updateOrCreate()


    PreferenceBuilder::initBulk([
        'name' => Preferences::LANGUAGE,
        'policy' => new MyPolicy()
     ]);

Routing

Off by default, enable it in the config

Warning

(Current) limitation: it's not possible to set object casts via API

Anantomy:

'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}

URI Parameters

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

Config Example:

 '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

Actions

Note

Examples are with scope user and group general

INDEX

GET

UPDATE

Enum Patching

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"}'

DELETE

Middlewares

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

Security

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

Upgrade from v1

  • implement PreferenceGroup in your Preference enums
  • implement PreferenceableModel in you all Models that want to use preferences
  • Switch from HasValidation to ValidationRule
  • 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.g PreferenceBuilder::init(VideoPreferences::QUALITY)->updateOrCreate();
    • bulk mode: initBulk as usual, as it works with upsert

Test

composer test

composer coverage

Test the pipeline locally

check out act install it via gh

then run: composer pipeline

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please check the License File for more information.

Support target

Package Version Laravel Version
1.x 10
2.x 10 & 11