Why you should use spatie/laravel-data to store "settings" in your Laravel app

• 5 min read

For a long time, I struggled with finding a good way to store settings for a user, team, or any other model in my Laravel app. Over the last 10 years, I've used different approaches to solve this problem.

I either added separate columns for each setting to the respective database table (e.g., a timezone or date_format column to the users table), created separate database tables to hold all the settings (e.g., a user_settings table with user_id, timezone, and date_format columns), or added a generic settings JSON column to my users table to store the settings.

Each method has its advantages and disadvantages. Adding separate columns is great if you need to query by a specific setting (e.g., "which users have selected a custom date format like YYYY-MM-DD?"). On the other hand, it can be excessive to add a new column just for a small setting, especially if your table has millions or billions of rows.

Separating the settings columns from the core table can be better for database performance. Your database doesn't have to load all settings columns all the time, which is useful when you need a list of users or projects in your database.

Adding a catch-all settings column is the easiest approach but can lead to performance issues and different structures between rows. It raises questions about how your app should react if an expected setting does not exist on a user. Do you have to check for the existence of the JSON key everywhere you check for the setting (e.g., $user->settings?->my_custom_setting)?

My solution to all of these problems is the spatie/laravel-data package developed by the Spatie team.

The spatie/laravel-data Approach #

The primary use case of the laravel-data package is to create strongly typed data objects in your Laravel projects. Here's an example of such a data object:

use Spatie\LaravelData\Data;

class SongData extends Data
{
    public function __construct(
        public string $title,
        public Artist $artist,
    ) {
    }
}

The package also supports Eloquent Casting, which means a data object can be saved to your database and, when retrieved, cast back into a strongly typed data object instance.

The combination of strong types and Eloquent casting inspired me to use this package to store settings in my apps.

An Example #

Here is a basic example of a UserSettings object I might add to any of my projects.

<?php

namespace App\Data;

use App\Domain\Support\Enums\ThemeApperance;
use Spatie\LaravelData\Data;

class UserSettings extends Data
{
    public function __construct(
        public string $timezone = 'UTC',
        public string $locale = 'en',
        public string $date_format = 'YYYY-MM-DD',
        public ThemeApperance $apperance = ThemeApperance::AUTO,
    ) {
        //
    }
}

In these settings we store the timezone, locale and preferred date format and theme apperance of a user.

After creating a migration that adds a settings column to my users-table, I update the User-model so that settings is cast to a UserSettings instance.

use App\Data\UserSettings;

/**
 * The attributes that should be cast.
 *
 * @var array<string, string>
 */
protected $casts = [
    'settings' => UserSettings::class . ':default',
];

Now, thanks to laravel-data, whenever I access $user->settings I always get an instance of UserSettings with all properties that I've declared in the PHP class.

If a user signed up two years ago and the app didn't support $date_format, the value on that user would just fall back to the default value I declared in the UserSettings-class.

The next time that user updates their settings, their outdated database state gets updated. No longer supported settings will be removed as well.

If you can't wait until your users update their settings, you can create an Artisan command that does this for you.

use App\Data\UserSettings;
use App\Models\User;

Artisan::command('app:update-user-settings', function () {
    // Get all Users and update their settings
    User::query()
        ->each(function (User $user) {
            // Update settings to the newest format
            $user->settings = UserSettings::from($user->settings);
            $user->save();
        });
});

The command loops over all users in your database and updates the settings-column with a fresh version. The current settings of a user (eg., their selection for $date_format) will be migrated.

Why This Is Great #

At the beginning, I mentioned that adding a catch-all settings column might be a bad idea if you don't have a structured way of storing settings. If different parts of your app add new keys to settings, it can become messy quickly. By using laravel-data, this problem goes away. There is one single source of truth for settings.

You can make settings strongly typed by using type hints and Enums. You could even create a nested structure of settings.

Imagine a UserSettings-class that includes a UserGeneralSettings, UserNotificationSettings and UserApperanceSettings.

<?php

namespace App\Data;

use App\Domain\Support\Enums\ThemeApperance;
use Spatie\LaravelData\Data;

class UserSettings extends Data
{
    public function __construct(
        public UserGeneralSettings $general,
        public UserNotificationSettings $notification,
        public UserApperanceSettings $apperance,
    ) {
        //
    }
}

One thing to keep in mind is that querying for specific settings can lead to performance issues and should probably be avoided.

If your app regularly needs to query for users who have selected a particular date_format , it's better to promote this setting to its own column. This makes the work of your database and possible indexing much easier. [1]

Closing Thought #

Going forward, I will use this approach for all cases where I need the concept of settings in new and existing apps.

I believe that using spatie/laravel-data and Eloquent casting is better than just putting your settings into a generic $settings array. I encourage you to give it a try in your next project.

Do you like this approach? Do you think this is a bad idea? Let me know!


  1. MySQL has support to index values inside a JSON column, but I would still promote the setting to its own column if I regularly query by it. ↩︎