Artwork's avatar

A Model with an attribute 'value' of type LaravelData for auto-validation

Dear Developers,

Thank you for the marvel!

I am currently trying to have "settings" in an application, and considered already two options:

  1. Use of LaravelData only with a custom repository to be stored in database manually;
  2. Use of a standard model with and attribute like value of type LaravelData.

In the first case, the custom storage implementation feels redundant in the project with no such added anywhere else where only models exist, but it seems the most easiest.

Therefore, I chose the second where settings would be stored in table settings and, to have the setting name more tightened, the model implementation is assumed to be extended with numerous settings extending it:

namespace App\Models\Settings;

use App\Enums\Settings\SettingName;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\LaravelData\Data;

class Setting extends Model
{
    use SoftDeletes;

    protected $table    = 'settings';

    protected $fillable = ['name', 'value'];

    protected $casts = [
        'name' => SettingName::class,
    ];

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);
    }
}
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('settings', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->json('value');
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('settings');
    }
};

Background Color Setting

namespace App\Settings;

use App\Enums\Settings\SettingName;
use App\Models\Settings\Setting;
use App\Settings\Data\BackgroundColorSettingData;

class BackgroundColorSetting extends Setting
{
    public function __construct(array $attributes = [])
    {
        $this->name           = SettingName::BackgroundColor;
        $this->casts['value'] = BackgroundColorSettingData::class;

        parent::__construct($attributes);
    }
}
namespace App\Settings\Data;

use App\Enums\Color;
use Illuminate\Validation\Rule;
use Spatie\LaravelData\Attributes\MergeValidationRules;
use Spatie\LaravelData\Data;

#[MergeValidationRules]
final class BackgroundColorSettingData extends Data
{
    public function __construct(
        public bool $enabled,
        public Color $color,
    ) { }
}

Sidebar Setting

namespace App\Settings;

use App\Enums\Settings\SettingName;
use App\Models\Settings\Setting;
use App\Settings\Data\SidebarSettingData;

class SidebarSetting extends Setting
{
    public function __construct(array $attributes = [])
    {
        $this->name           = SettingName::Sidebar;
        $this->casts['value'] = SidebarSettingData::class;

        parent::__construct($attributes);
    }
}

Settings Service

In order to get the setting by its name only, I tried to implement a service with a "resolver", yet am not sure if that's the most convenient way.

namespace App\Providers;

use App\Repositories\SettingsRepository;
use App\Services\Settings\SettingResolver;
use App\Services\Settings\SettingsService;
use Illuminate\Support\ServiceProvider;

class SettingsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(SettingsService::class, fn () => new SettingsService(
            new SettingsRepository(),
            new SettingResolver()
        ));
    }
}

Settings Repository

Setting Resolver (from Enum to Class)


In the result, we have:

true
App\Settings\BackgroundColorSetting {#8752
    name: "background_color",
    value: "{"enabled":true,"color":"red"}",
}
false
App\Settings\BackgroundColorSetting {#8752
    name: "background_color",
    value: "{"enabled":false,"color":"yellow"}",
}
false
App\Settings\BackgroundColorSetting {#8752
    name: "background_color",
    value: "{"enabled":true,"color":"white"}",
}

Yet dear, not only the validation does not work, but it feels like it's not quite adequate, where a model is used as a repository, not validation happens on set part, and what do you think about the setting resolution via "FQCN" by the class name associated with the Enum?

Best and kind regards

0 likes
3 replies
LaryAI's avatar
Level 58

Great question! You're on the right track with your architecture, but there are a few improvements you can make for better separation of concerns, validation, and type safety. Below are my recommendations and a concrete solution.


1. Validation with LaravelData

You want to leverage Spatie's Laravel Data objects for validation and type safety. The main issue is that your SettingsService::set() method is storing raw arrays (or whatever is passed in), without validating or mapping them to the correct Data class.

Solution:

  • Always resolve the correct Data class for the setting.
  • Validate and map the input to that Data class.
  • Store the serialized Data object.

2. Decouple Model from Data

Your Setting Eloquent model should just be a data store. The actual logic for validation and transformation should live in your Data classes and service layer.


3. How to Validate on Set

Here's how you can implement validation using LaravelData in your SettingsService::set() method:

public function set(SettingName $name, array $value): bool
{
    // 1. Resolve the Setting class (e.g., BackgroundColorSetting)
    $setting = $this->resolver->resolve($name);

    // 2. Get the Data class associated with this setting
    $dataClass = $setting->getDataClass();

    // 3. Validate and create the Data object
    /** @var \Spatie\LaravelData\Data $data */
    $data = $dataClass::from($value); // This will throw if validation fails

    // 4. Store the serialized data
    return SettingsRepository::set($name, $data->toJson());
}

4. How to Map SettingName to Data Class

Add a method to your Setting base class:

abstract class Setting extends Model
{
    // ...

    abstract public function getDataClass(): string;
}

And in your concrete setting:

class BackgroundColorSetting extends Setting
{
    public function getDataClass(): string
    {
        return BackgroundColorSettingData::class;
    }
}

5. Getting the Setting

When retrieving, you can hydrate the Data object:

public function get(SettingName $name, mixed $default = null): Setting
{
    try {
        $serializedSettingValue = SettingsRepository::get($name);
    } catch (SettingValueNotFoundException $exception) {
        if ($default === null) {
            throw $exception;
        }
        $serializedSettingValue = value($default);
    }

    $setting = $this->resolver->resolve($name);

    $dataClass = $setting->getDataClass();
    $setting->value = $dataClass::from(json_decode($serializedSettingValue, true));

    return $setting;
}

6. Optional: Custom Eloquent Cast

If you want to automatically cast the value attribute to the correct Data class, you can create a custom Eloquent cast, but since each setting has a different Data class, it's easier to handle this in your service layer as above.


7. Enum-to-Class Mapping

Your resolver is fine, but you could use a static map for more explicitness and IDE support:

protected array $map = [
    SettingName::BackgroundColor->value => BackgroundColorSetting::class,
    // ...
];

public function resolve(SettingNameInterface $name): Setting
{
    $class = $this->map[$name->value] ?? null;
    if (!$class || !class_exists($class)) {
        throw new RuntimeException("Setting not found: {$name->value}");
    }
    return app()->make($class);
}

8. Summary

  • Validate input using the correct Data class before storing.
  • Store the serialized Data object.
  • Hydrate the Data object when retrieving.
  • Keep your Eloquent model simple; put logic in Data classes and services.

Example Implementation

Setting Base Class

abstract class Setting extends Model
{
    use SoftDeletes;

    protected $table = 'settings';
    protected $fillable = ['name', 'value'];
    protected $casts = [
        'name' => SettingName::class,
    ];

    abstract public function getDataClass(): string;
}

BackgroundColorSetting

class BackgroundColorSetting extends Setting
{
    public function getDataClass(): string
    {
        return BackgroundColorSettingData::class;
    }
}

SettingsService::set

public function set(SettingName $name, array $value): bool
{
    $setting = $this->resolver->resolve($name);
    $dataClass = $setting->getDataClass();

    // Validate and create Data object
    $data = $dataClass::from($value); // Throws if invalid

    // Store as JSON
    return SettingsRepository::set($name, $data->toJson());
}

SettingsService::get

public function get(SettingName $name, mixed $default = null): Setting
{
    try {
        $serializedSettingValue = SettingsRepository::get($name);
    } catch (SettingValueNotFoundException $exception) {
        if ($default === null) {
            throw $exception;
        }
        $serializedSettingValue = value($default);
    }

    $setting = $this->resolver->resolve($name);
    $dataClass = $setting->getDataClass();
    $setting->value = $dataClass::from(json_decode($serializedSettingValue, true));

    return $setting;
}

Conclusion

  • Do not store raw arrays or unvalidated data.
  • Always use your Data classes for validation and serialization.
  • Keep your Eloquent model simple.
  • Let your service layer handle the mapping and validation.

This approach gives you strong typing, validation, and a clean separation of concerns.


If you need a full code example or further clarification, let me know!

Glukinho's avatar
Level 31

How about taking spatie/laravel-settings package with all this already implemented?

1 like
Artwork's avatar

@Glukinho, I checked the package and, apparently, I tried to implement above something indeed similar, including the automated settings searching mechanism and caching.

It's still interesting what was the issue, but I will dig deeper into their implementation due to relatively short time available, where the above was already the third attempt to handle the local project's legacy code base in addition to the "settings".

Thank you very much for mentioning the miraculous package someone had time enough to implement! ✨

Please or to participate in this conversation.