LaravelPackages.net
Acme Inc.
Toggle sidebar
matteoc99/laravel-preference

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

46
4
v2.2.0
About matteoc99/laravel-preference

matteoc99/laravel-preference is a Laravel package for laravel package that aims to store and manage user settings/preferences in a simple and scalable manner. It currently has 4 GitHub stars and 46 downloads on Packagist (latest version v2.2.0). Install it with composer require matteoc99/laravel-preference. Discover more Laravel packages by matteoc99 or browse all Laravel packages to compare alternatives.

Last updated

Laravel User Preferences

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

  • Event System -> #13

  • Api Response customization -> #14

  • QoL Helpers functions

  • Caching

  • Blade Directives

  • Additional suggestions are welcome. (check out Contributing)

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

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

GET

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

UPDATE

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

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

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

Contributing

See Contributing for details.

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 | PHP | Maintained | |-----------------|-----------------|-------|------------| | 1.x | 10 | >=8.1 | ❌ | | 2.0 & 2.1 | 10 & 11 | >=8.1 | ❌ | | ^2.2 | 10 & 11 & 12 | >=8.2 | ✅ |

Comments