Record
An array carries no type information. When a request body or a database row is passed as an array, every consumer must check which keys exist and which types they have. A Record is a class instead. It declares the fields with types and default values, so the rest of the code can rely on them. Record also fills itself from an array or an object, creates changed copies, and controls how it is converted to JSON.
Record is built from the RecordTrait trait. You can use the trait in your own base class if you already extend another class.
Defining a Record
Extend Record and declare the fields as typed public properties. A constructor is optional. When it exists, its parameter names are used to pass values from the array when the object is wrapped:
use Windwalker\Data\Record;
class Customer extends Record
{
public string $name = '';
public string $tier = 'basic';
public array $tags = [];
public function __construct(string $name = '')
{
$this->name = $name;
}
}
$customer = Customer::wrap(['name' => 'Rose', 'tier' => 'gold']);Showcase
The example below creates a customer from a request array, creates a changed copy for an upgrade, applies defaults, and encodes the result as JSON. The password property is excluded from the JSON output:
use Windwalker\Data\Record;
use Windwalker\ORM\Attributes\JsonNoSerialize;
class Customer extends Record
{
public string $name = '';
public string $tier = 'basic';
public array $tags = [];
#[JsonNoSerialize]
public string $password = '';
}
$customer = Customer::wrap([
'name' => 'Rose',
'tier' => 'gold',
'password' => 'secret',
]);
$upgraded = $customer->with(tier: 'platinum');
$customer = $customer->withDefaults(
['tier' => 'basic', 'tags' => ['new']],
true
);
echo $customer->tier; // gold
echo $upgraded->tier; // platinum
echo json_encode($customer);
// {"name":"Rose","tier":"gold","tags":["new"]}The JSON output does not contain password, because the attribute #[JsonNoSerialize] removes it. The attribute comes from windwalker/orm, so that package must be installed in the project. Use #[JsonSerializer] with a callable to transform a value during serialization instead of removing it.
Wrapping Data
wrap() creates an instance from an array, an object, or null. When the value is already an instance of the class, wrap() returns it unchanged:
$same = Customer::wrap($customer); // same instance
$copy = Customer::wrap($customer, true); // cloned instanceUse wrapWith() when the arguments are written as named parameters. It always returns a clone, so the original object is never changed:
$rose = Customer::wrapWith(name: 'Rose', tier: 'gold');tryWrap() and tryWrapWith() return null when the input is null, which is useful for optional data.
Changing a Record
A record has two styles of change:
- In-place methods, such as
fill(),merge(), anddefaults(), change the current object and return$this. - Copy methods, such as
with(),withMerge(), andwithDefaults(), return a changed clone and keep the original object.
$customer->merge(['name' => 'Sakura']);
$updated = $customer->withMerge(['tier' => 'silver']);merge() assigns each value to a property with the same name. It skips private, protected, and readonly properties. This protects a record from mass assignment: a request array cannot overwrite an internal field such as an account ID or a role, even when the array contains that key.
WARNING
Copy methods such as with() and withMerge() do not change the original object. If you call them without using the result, nothing happens. PHP marks these methods with #[\NoDiscard], so a discarded result triggers a warning:
$customer->with(tier: 'platinum'); // no effect
$customer = $customer->with(tier: 'platinum'); // correctMerging Recursively
Set the second argument to true to merge nested arrays and nested records instead of replacing them. Set the third argument to true to skip null values:
$base = Customer::wrap(['tags' => ['vip' => true]]);
$merged = $base->withMerge(
['tags' => ['trial' => true], 'name' => null],
true,
true
);
// tags: ['vip' => true, 'trial' => true]
// name: unchanged, because null was skippedDefaults
defaults() and withDefaults() fill in values that are missing or null. Existing non-null values always win. Pass true as the second argument to merge arrays recursively, so an empty list receives the default items:
$customer = Customer::wrap(['tier' => 'gold']);
$customer->defaults(['tier' => 'basic', 'tags' => ['new']], true);
echo $customer->tier; // gold
echo json_encode($customer->tags); // ["new"]Converting Records
dump() returns an array of the properties. Pass true to convert nested records as well. as() converts the record into another record class. It calls that class's wrap() method:
$array = $customer->dump();
$other = $customer->as(OtherCustomer::class);INFO
Record assigns values directly to the properties. It does not call setters or apply #[Cast] attributes. Assigning a value of the wrong type throws a TypeError, because the trait is written with strict types:
Customer::wrap(['tier' => 1]);
// TypeError: Cannot assign int to property Customer::$tier of type stringConvert the value before you wrap it, or use an ORM #[Cast] attribute on an entity column when the data comes from the database.
When an ORM entity column uses a Record class as its cast target, the ORM calls wrap() on the stored value:
use Windwalker\ORM\Attributes\Cast;
use Windwalker\ORM\Attributes\Column;
use Windwalker\ORM\Attributes\PK;
use Windwalker\ORM\Attributes\Table;
#[Table('flowers')]
class FlowerEntity
{
#[Column('id'), PK]
public ?int $id = null;
#[Column('meta'), Cast(FlowerMeta::class)]
public ?FlowerMeta $meta = null;
}Undeclared Keys
A key that has no matching property is a dynamic property. PHP 8.2 and later report a deprecation for each one. Declare every field in the class instead of relying on undeclared keys:
#[\AllowDynamicProperties]
class LooseCustomer extends Record {}ValueObject
ValueObject is the older base class for data objects. It runs setters and #[Cast] attributes when it hydrates. It is deprecated, so write new code with Record and convert values in your own methods.