Eloquent casts
Mapped casts a model attribute to a class. Any class is stored as a JSON object:
it is mapped and validated with Mapper::fromArray() when read or set from an array, and
stored with Profile::storage(), including #[\SensitiveParameter] properties. A class that implements StringBacked is stored as a
string instead.
use App\Values\Postcode;
use App\Values\Settings;
use BYanelli\Roma\Eloquent\Mapped;
class Account extends Model
{
protected function casts(): array
{
return [
'settings' => Mapped::to(Settings::class), // a JSON column
'postcode' => Mapped::to(Postcode::class), // a string column
];
}
}
Mapped::to() returns a cast string, so it goes in the casts() method. In the
$casts property, write the same string: Mapped::class.':'.Settings::class.
JSON object columns
The class is mapped from the stored JSON as Mapper::fromArray() maps an array, so the
class's keys, defaults, coercion and #[Rule]s apply, and keys the class doesn't have are
ignored. #[SnakeCaseKeys] on the class gives the column snake_case keys:
use BYanelli\Roma\Attributes\SnakeCaseKeys;
#[SnakeCaseKeys]
readonly class Settings
{
public function __construct(
public string $theme = 'light',
public int $pageSize = 20,
) {}
}
$account->settings = new Settings(pageSize: 50); // stored as {"theme":"light","page_size":50}
$account->settings = ['page_size' => '50']; // mapped first, so stored the same way
$account->settings->pageSize; // 50
$account->toArray()['settings']; // ['theme' => 'light', 'page_size' => 50]
An array is mapped before it is stored, and invalid data throws
MappingValidationException, as Mapper::fromArray() does. Setting a value that is neither
the class, an array nor null throws InvalidArgumentException, and so does a non-empty list. toArray() and toJson()
write the storage array without #[\SensitiveParameter] properties, as
Serializer::toSafeArray() does, so a model returned from a controller doesn't send them.
A required array property may be empty: a class with list<string> $tags stores {"tags":[]}
and reads it back. A stored [] for a non-empty-list<T> is invalid and throws when read, as
below. See Empty lists and objects.
A null column reads as null. To read it as the class mapped from an empty array, pass
defaultWhenNull. The column stays null until the attribute is set:
'settings' => Mapped::to(Settings::class, defaultWhenNull: true),
$account->settings; // new Settings, even when the column is null
String columns
A value object stored as one string, such as a postcode or a formatted identifier,
implements StringBacked: parse() returns the object for a string or null when the string
isn't one, and toString() returns a string that parse() maps back to an equal object.
use BYanelli\Roma\Eloquent\StringBacked;
final readonly class Postcode implements StringBacked
{
public function __construct(public string $digits) {}
public static function parse(string $value): ?static
{
return preg_match('/^\s*(\d{5})\s*$/', $value, $m) === 1 ? new self($m[1]) : null;
}
public function toString(): string
{
return $this->digits;
}
}
$account->postcode = Postcode::parse(' 02139 '); // stored as "02139"
$account->postcode = ' 02139'; // stored as " 02139"
$account->postcode->digits; // "02139"
$account->toArray()['postcode']; // " 02139", the stored string
An object is stored as its toString(). A string is stored as given, after checking that it
parses, so the column keeps the original format. toArray() and toJson() write the stored
string.
A null column reads as null. To read it as an object instead, implement HasDefault and pass
defaultWhenNull. The column stays null until the attribute is set, and toArray() and
toJson() write null for it, since they write the stored string. Setting the default object
stores its toString().
use BYanelli\Roma\Eloquent\HasDefault;
use BYanelli\Roma\Eloquent\StringBacked;
final readonly class Region implements HasDefault, StringBacked
{
// parse() and toString() as above
public static function default(): static
{
return new self('ZZ');
}
}
'region' => Mapped::to(Region::class, defaultWhenNull: true),
$account->region; // Region::default(), even when the column is null
$account->toArray()['region']; // null when the column is null
defaultWhenNull on a StringBacked class that doesn't implement HasDefault throws
InvalidArgumentException when the cast is resolved.
Invalid values
A stored value that can't be read throws BYanelli\Roma\Eloquent\InvalidAttributeValue, an
UnexpectedValueException. That is a JSON value that isn't an object or doesn't map, such as
a string or a non-empty list, or a string that parse() rejects. A stored [] maps as {}
does, from an empty array, since the two decode to the same PHP array. The message gives the model's class, key and the attribute,
and the exception's modelClass, modelKey and attribute properties contain them. A
mapping failure is its previous exception:
App\Models\Account 12 has an invalid settings: it is not a JSON object
Pass nullWhenInvalid to read such a value as null instead. A StringBacked attribute then
also accepts a string that doesn't parse, and stores it as given, which suits a column that
keeps raw input:
'postcode' => Mapped::to(Postcode::class, nullWhenInvalid: true),
$account->postcode = 'unknown'; // stored as "unknown"
$account->postcode; // null
$account->getAttributes()['postcode']; // "unknown"
An attribute that reads as null serializes as null, since Laravel serializes a null cast value without calling the cast.
| Option | JSON object column | String column |
|---|---|---|
| neither | null reads as null; invalid data throws | null reads as null; an invalid string throws |
defaultWhenNull |
null reads as the class mapped from [] |
null reads as default(), and serializes as null; throws without HasDefault |
nullWhenInvalid |
invalid data reads as null | an invalid string reads as null and can be set |
In a $casts string, the options are default-when-null and null-when-invalid after the
class: Mapped::class.':'.Postcode::class.',null-when-invalid'.
Object caching
Laravel caches a cast object and writes it back through the cast when the model is saved.
Mapped turns that off, so reading an attribute never changes what is stored: a
column with keys the class doesn't have, a string in a format other than toString()'s,
and a null column read with defaultWhenNull are saved unchanged. Mapped classes are
usually readonly, so assign a new object to change the attribute.
Creating and updating models
The StoredFromObjects trait creates and updates a model from a mapped object, such as a
request object. Both methods serialize the object with Serializer::toArray(), which uses
Profile::storage(), and fill the model with the result, so the model's $fillable and
$guarded apply. The object's keys are the column names, so #[SnakeCaseKeys] on the class
gives snake_case columns.
use BYanelli\Roma\Eloquent\StoredFromObjects;
class Contact extends Model
{
/** @use StoredFromObjects<ContactRequest, ContactRequest> */
use StoredFromObjects;
protected $fillable = ['name', 'email', 'phone'];
}
Route::post('/contacts', fn (ContactRequest $request) => Contact::createFromObject($request));
Route::put('/contacts/{contact}', fn (Contact $contact, ContactRequest $request) => $contact->updateFromObject($request));
createFromObject() serializes with Operation::Create and returns static::create()'s model.
A Missing property is left out, so its column takes its database default, unless the
property is required in a Create, such as one with #[RequiredOnCreate] or
#[OptionalOnUpdate]; then it throws InvalidArgumentException, listing the properties.
updateFromObject() serializes with Operation::Update and returns update()'s result.
Missing properties are left out, so their columns keep their values. With
#[OptionalOnUpdate] and #[RequiredOnCreate] (see Validation), one
class can serve both routes.
By default an object property replaces its column, so keys the request leaves out are lost.
#[MergeOnUpdate] on a nested object property, typically SomeInput|Missing|null, makes
updateFromObject() merge the serialized object over the column's stored JSON object instead:
keys the object leaves out, such as Missing ones, keep their stored values, and keys it
writes replace them. The merge is shallow, so a nested object inside it is still replaced.
use BYanelli\Roma\Attributes\MergeOnUpdate;
#[SnakeCaseKeys]
readonly class UpdateExportRequest
{
public function __construct(
#[MergeOnUpdate]
public ExportConfigInput|Missing|null $config,
) {}
}
// Stored config: {"to": ["[email protected]"], "password": "secret"}
// PATCH {"config": {"to": ["[email protected]"]}} stores {"to": ["[email protected]"], "password": "secret"}
The column is merged by its key in the serialized array, so #[Key] and #[SnakeCaseKeys]
apply. Its stored value is read raw, without the cast, so a null column is set to the object as
it is, even under defaultWhenNull. Null still clears the column, and Missing still leaves it
alone. The attribute has no effect in createFromObject() or in a nested object. The class
definition throws when the property's type is not a nested object class, and
updateFromObject() throws InvalidArgumentException when the attribute has no array, JSON or
class cast, or its stored value is not a JSON object. A non-empty list such as ["x"] isn't one,
while {"0":"x"} is, and a stored [] counts as {}, as in the Mapped cast. An object that
writes no keys leaves a stored value as it is.
Upserting
upsertFromObject() inserts a row from an object, or updates the row that has the same values
in the $uniqueBy columns, through Eloquent's upsert(). upsertManyFromObjects() does the
same for an iterable of objects in a single query, and returns 0 without a query when it is
empty. Both return the number of affected rows as the database driver reports it.
Product::upsertFromObject($product, uniqueBy: ['vendor_id', 'sku']);
foreach (array_chunk($products, 500) as $chunk) {
Product::upsertManyFromObjects($chunk, uniqueBy: ['vendor_id', 'sku'], update: ['price']);
}
The objects are serialized with Operation::Create, since a row may be inserted: a Missing
property that is required in a Create throws, and other Missing properties are left out, so
on update their columns keep their values. $update lists the columns to update when the row
exists; null, the default, updates all the row's columns except created_at. Eloquent sets
created_at and updated_at when the model uses timestamps.
The query builder's upsert() skips casts and mass assignment, so each row is filled into a
new model first and its raw attributes are sent. The model's casts apply, as do $fillable and
$guarded, so the $uniqueBy columns must be fillable. The model's default $attributes are
not used, so a column the object leaves out takes its database default on insert.
All rows in one upsertManyFromObjects() call must have the same columns, as the query builder
requires, so a Missing property must be Missing in all the objects or in none. Otherwise
it throws InvalidArgumentException before running a query. The method doesn't chunk the
input; a caller with many rows passes them in chunks, since a database limits the number of
bindings in a query.
Type checks
The trait's three template types are the classes createFromObject(), updateFromObject() and
the upsert methods accept, which PHPStan checks. Without the @use tag, all of them accept any
object. A model that is never updated from an object can pass never as the second type, and
PHPStan then reports any call to updateFromObject():
/** @use StoredFromObjects<NewContactRequest, never> */
use StoredFromObjects;
A model that is only upserted passes never for the first two:
/** @use StoredFromObjects<never, never, ImportedProduct> */
use StoredFromObjects;
Storing only validated objects
The trait and the Mapped cast store any object they are given, including one built with new
that was never checked against its rules. An object that breaks its rules is then stored, and every
later read of a Mapped attribute throws InvalidAttributeValue.
Mapper::preventStoringUnvalidatedObjects() makes the four methods, and setting an object on a
Mapped attribute, throw UnvalidatedObjectException for an object Roma didn't validate. It is off
by default. As with
Eloquent's Model::preventLazyLoading(), turn it on in a service provider's boot() method,
usually outside production:
use BYanelli\Roma\Facades\Mapper;
public function boot(): void
{
Mapper::preventStoringUnvalidatedObjects(! $this->app->isProduction());
}
An object is validated when Roma built it by mapping, as a request object, a #[RequestBody]
or #[RequestQuery] parameter, a config object, an HTTP client response or the result of
Mapper::fromArray(), or when it passed Mapper::validate().
Objects nested in it are recorded too, including those in lists, maps, shapes and unions, under
the same Operation, so $details->config = $request->config is accepted. The object in a
#[MapFromValue] property of a constructed object isn't, since validation skips that property.
Mapper::preventStoringUnvalidatedObjects(false) turns the check off again.
Roma records the Operation each object was validated under. updateFromObject() accepts
objects validated under any operation. createFromObject() and the upsert methods may insert a
row, so they accept:
- an object validated with
Operation::Create; - an object validated with
Operation::None, such as one fromMapper::fromArray()with no operation or from a GET request, if neither its class nor a class nested in it has a#[RequiredOnCreate]property. Only aCreaterequires those, so underNonean empty string passes for one, as does aMissingone in a nested object. To store such an object, map it withOperation::Createor pass it toMapper::validate($object, Operation::Create).
They throw for an object validated with Operation::Update, such as a PUT or PATCH request
object, since an Update doesn't require #[OptionalOnUpdate] properties either.
In a test that builds a fixture with new, pass it to Mapper::validate(), which also checks
it against its rules. Mapper::markValidated($object, $operation) records an object as
validated without checking it, and returns it.
A Mapped attribute reads with Operation::None, so setting an object accepts one validated under
Create or None, and throws for one validated under Update, whose #[OptionalOnUpdate]
properties may be Missing. An array set on the attribute is mapped as usual, so it needs no
record, and neither does a StringBacked object, since parse() and toString() define its value.
With createFromObject() and updateFromObject(), nested objects reach the cast as arrays.
The check compares objects by identity, so it is reliable for immutable objects, such as
readonly classes. A change to a mutable object after validation isn't detected. A clone,
including one made with clone($object, [...]), and an unserialized object, such as one passed
to a queued job, are new objects and aren't validated until they are passed to
Mapper::validate().
The validated objects are recorded in a WeakMap, which drops an entry when its object is
garbage collected, so the record doesn't grow in a long-running Octane worker. The setting from
preventStoringUnvalidatedObjects() applies to all requests the worker handles.