Storing classes with user configurable parameters in the database (with Laravel)
Tags: laravel, php, database
Published: 2024-02-12 09:43:52

In a current Laravel-based software project I'm developing, I was confronted with a small challenge that kept me thinking more than perhaps it should have: how to efficiently store an "abstraction" of certain classes in the database, combined with user-configured inputs, to later instantiate the corresponding class with the correct parameters. Although I searched through existing solutions, I could not find one that met the requirements.

Imagine the following: Users can choose from some actions such as "SendEmail" or "AssignEmployee" to be executed when certain events occur. Each action requires certain inputs, e.g. email addresses or employee IDs. How can this data be stored elegantly and the associated tasks, each of which has its own class, executed precisely with the right parameters?

At first glance, serializing and storing a class with the correct properties from the user input seems to be a straightforward solution. However, it requires a number of somewhat unwieldy steps downstream. I have also seen approaches to use DataTransferObjects to store the values configured by the user and then extend the DTOs with business logic. But even though this approach works quite easily, it seemed wrong to combine these things.

So I've tried to find my own solution...

### The Initial Approach

Serialization appeared as an initial contender. However, there are two key points that I felt were not ideal.

A) **Updating Configured Parameters:** Modifying user-configured parameters proved to be a bit cumbersome, requiring deserialization, property updates, and serialization — a rather tedious process.

B) **Hardcoded Namespace:** Any changes in namespace would render the serialized data unusable — an inflexible solution.

Readability of the stored data aside, While serialization may suffice for one-off actions, something that may be performed once, and the record will be removed from the database. But to me it falls short when data longevity and flexibility are more crucial.

### A Refined Approach

To address these challenges, I explored alternatives. Storing just the class name and a JSON-encoded array of parameters within the database emerged as a promising alternative. But how to automate class initialization with the correct parameters?

#### Handling  (typed) constructor properties
I use typed constructor properties for these user-configurable classes, so they can be reused and make it obvious what’s required to create an instance. But that's why I can't just throw a  "$params" array into the constructor. So I opted for a static creation method like "fromArray" which will take care of creating a new instance with array values, to streamline instantiation, while still providing clarity by the expected constructor inputs.

```php

class SendEmailAction {
    public function __construct(
        protected string $toEmail
    ){}
    public static function from Array(array $params){
        return new self(
            toEmail: $params['email']
        );
    }
}
```

For the sake of readability the code snippets will only contain the very basic information required. Of course error handling or validation should be improved in a real world situation. 
#### Enum-Based Resolution
To eliminate hard-coded namespaces in the database, I used enums to decouple stored action names from class namespaces. By storing enum values in the database and dynamically resolving the corresponding class names, I have eliminated namespace dependencies and improved scalability.

```php
enum ActionClassOptions: string {
    case SendEmail = 'send_email';
    // …    
    public function getClass(){
        return match($this) => {
            self::SendEmail => SendEmailAction::class
        }
    }
}
```

### Storing the data
Since I didn't want to create an additional database table just for this purpose, I decided to store all this data in a json column. I think this is a fair solution, especially since the data is not queried. However, storing it in a normalized database table can be beneficial in other cases as well.

So, for my Laravel project I had to add a new `json` column to the migration and a cast definition to the model:

```php
class User extends Model {
    protected $casts = [
        'configured_actions' => 'array'
    ];
}
```

The data stored in this JSON column is simply a string for the action (from the backed enum) and an array of parameters.

```php
$user->configured_actions = [
    [
        'action' => ActionClassOptions::SendEmail,
        'params' => [
            'email' => 'foo@bar.com'
        ]
    ],
    [
        'action' => ActionClassOptions::AnotherOption,
        'params' => [
            'employee_id' => 'some_id'
        ]
    ]
];
```

Now, all that remains is to instantiate the actions with the correct data when the time comes. For this, I added a `getConfiguredActions()` method to the model (but this could be any kind of service class), returning executable instances of the action classes:

```php
public function getEventActions() {
    return array_map(function($actionData){
        $actionOption = ActionClassOption::tryFrom(
            $actionData['action']
        );
        return $actionOption->getClass()::fromArray(
            $actionData['params']
        );
    }, $this->configured_actions);
}
```

### Alternative Solutions?
Even though my solution somehow sufficiently addresses the challenges encountered, I am interested in exploring alternative approaches. I'm sure there are a lot of other and probably better ways to solve this problem, as this issue is not limited to Laravel or even PHP.
And maybe I'm just overthinking it?
Have you ever encountered a similar situation in your projects? What solutions did you choose?