<?php
namespace App\ValueObjects;
final readonly class NotificationSettings
{
public function __construct(
public bool $emailEnabled,
public bool $smsEnabled,
public string $digestFrequency,
public array $mutedChannels,
) {
}
public static function fromArray(array $data): self
{
return new self(
emailEnabled: (bool) ($data['email_enabled'] ?? true),
smsEnabled: (bool) ($data['sms_enabled'] ?? false),
digestFrequency: $data['digest_frequency'] ?? 'daily',
mutedChannels: array_values($data['muted_channels'] ?? []),
);
}
public function toArray(): array
{
return [
'email_enabled' => $this->emailEnabled,
'sms_enabled' => $this->smsEnabled,
'digest_frequency' => $this->digestFrequency,
'muted_channels' => $this->mutedChannels,
];
}
public function with(array $changes): self
{
return self::fromArray(array_merge($this->toArray(), $changes));
}
}
<?php
namespace App\Casts;
use App\ValueObjects\NotificationSettings;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
final class NotificationSettingsCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): NotificationSettings
{
$decoded = is_string($value) ? json_decode($value, true) : [];
return NotificationSettings::fromArray(is_array($decoded) ? $decoded : []);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if ($value instanceof NotificationSettings) {
return json_encode($value->toArray(), JSON_THROW_ON_ERROR);
}
if (is_array($value)) {
return json_encode(NotificationSettings::fromArray($value)->toArray(), JSON_THROW_ON_ERROR);
}
throw new InvalidArgumentException(
'notification_settings must be a NotificationSettings instance or array.'
);
}
}
<?php
namespace App\Models;
use App\Casts\NotificationSettingsCast;
use App\ValueObjects\NotificationSettings;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected $casts = [
'notification_settings' => NotificationSettingsCast::class,
'email_verified_at' => 'datetime',
];
protected $attributes = [
'notification_settings' => '{}',
];
public function muteEmails(): void
{
/** @var NotificationSettings $settings */
$settings = $this->notification_settings;
$this->notification_settings = $settings->with([
'email_enabled' => false,
'muted_channels' => array_unique([...$settings->mutedChannels, 'email']),
]);
$this->save();
}
}
This snippet shows how a JSON settings column on an Eloquent model can be surfaced as a typed, immutable value object instead of a loosely-typed associative array. The problem it solves is that raw JSON columns leak untyped array data everywhere: callers guess key names, forget defaults, and mutate nested arrays without any guarantee the shape stays valid. A CastsAttributes implementation moves that structure into one place.
The NotificationSettings tab defines the value object. It is a readonly class with typed properties, so once constructed it cannot be mutated in place — any change produces a new instance via with(), which mirrors how immutable value objects behave elsewhere. fromArray() centralises defaulting and coercion so missing keys never blow up, while toArray() provides the canonical serialisation. Keeping both directions here means the JSON shape has exactly one source of truth.
The NotificationSettingsCast tab implements Laravel's CastsAttributes interface. Its get() receives the raw string pulled from the database and rehydrates it into a NotificationSettings object, tolerating null by falling back to defaults. set() accepts either an existing value object or a plain array, validates the type, and returns the JSON string that Eloquent actually persists. Returning an encoded string from set() is important: the cast owns serialisation, so the model column stays a real JSON string and the object never bleeds into the query builder.
The User model tab wires the cast in via the $casts array using the class-string form. Because Eloquent memoises cast results per attribute, repeated reads of $user->notification_settings return the same object without re-parsing JSON. The muteEmails() helper demonstrates the immutable update dance: read the value object, derive a new one with with(), and assign it back so set() re-serialises on save.
The trade-off is a little ceremony compared to a bare array, and callers must remember that mutating the returned object does nothing until reassigned. In return the model gains a validated, self-documenting API, IDE autocompletion on settings, and a single choke point for migrating the JSON schema. This pattern fits any column holding structured configuration — feature flags, preferences, or embedded metadata.
Related snips
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
Value objects for domain modeling
class Product(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost = models.DecimalField(max_digits=10, decimal_places=2)
margin = models.DecimalField(max_digits=5, decimal_places=2, blank=True)
Django model signals vs overriding save
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
# Vulnerable: user input is concatenated directly into SQL.
email = params[:email]
password = params[:password]
sql = "SELECT * FROM users WHERE email = '#{email}' AND password_hash = '#{password}'"
user = ActiveRecord::Base.connection.execute(sql).first
SQL injection prevention with unsafe and safe query patterns
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.