zero-to-prod/data-model
Reflection-based PHP data models that hydrate from arrays/objects via a single from() call. Use attributes to centralize casting, validation, defaults, required/nullable rules, and assignment behavior—ideal for external data like APIs, DB rows, and user input.
## Getting Started
### Minimal Setup
1. **Installation**: Add the package via Composer:
```bash
composer require zero-to-prod/data-model
DataModel trait to any class and define typed properties:
use Zerotoprod\DataModel\DataModel;
class User {
use DataModel;
public string $name;
public int $age;
}
$user = User::from(['name' => 'John', 'age' => 30]);
Describe Attribute: Core configuration for property hydration (e.g., #[Describe(['required'])]).assign > default > cast).class ApiUserResponse {
use DataModel;
#[Describe(['required'])]
public string $username;
#[Describe(['cast' => 'intval'])]
public int $userId;
}
$response = ApiUserResponse::from(json_decode(file_get_contents('api-response.json'), true));
DataModel trait and declare properties with type hints.#[Describe] to customize behavior per property.Model::from($data) with any array-like input.class User {
use DataModel;
#[Describe([
'required',
'cast' => [self::class, 'sanitizeName']
])]
public string $name;
public static function sanitizeName(string $name): string {
return trim(strtolower($name));
}
}
class Address {
use DataModel;
public string $street;
}
class User {
use DataModel;
public Address $address; // Automatically hydrated
}
class User {
use DataModel;
#[Describe(['default' => 'Guest'])]
public string $name;
#[Describe(['default' => 0])]
public int $loginAttempts;
}
class Order {
use DataModel;
#[Describe(['pre' => [self::class, 'validateAmount']])]
public float $amount;
public static function validateAmount(float $amount): void {
if ($amount <= 0) throw new \InvalidArgumentException('Amount must be positive');
}
}
$user = User::from(request()->all());
class UserResource extends JsonResource {
public function toArray($request) {
return User::from($this->resource->toArray())->toArray();
}
}
use Zerotoprod\DataModel\DataModelHelper;
$users = DataModelHelper::mapOf(User::class)
->from(array_map('stdClass', $rawUsers));
Circular References
User->orders and Order->user) without custom via logic.#[Describe(['via' => [self::class, 'hydrateUser']])] with lazy loading.Type Mismatches
#[Describe(['cast' => [self::class, 'ensureString']])]
public string $name;
public static function ensureString(mixed $value): string {
if (!is_string($value)) throw new \TypeError("Expected string, got " . gettype($value));
return $value;
}
Attribute Overrides
#[Describe] on the same property throw DuplicateDescribeAttributeException.#[Describe('email')]
public function normalizeEmail(string $email): string {
return strtolower(trim($email));
}
Recursive Hydration Depth
xdebug.max_nesting_level or use #[Describe(['via' => 'customHydrator'])].Context Key Conflicts
from is remapped to a key that doesn’t exist in $data, it silently fails.required: true or add a default.Inspect Resolution Enable debug mode in the package config:
'debug' => true, // Logs resolution steps to stderr
Or manually trace:
$user = User::from($data, debug: true);
Validate Attributes
Use PHPStan or Psalm to catch invalid Describe configurations early.
Test Edge Cases
int vs string casts).Cache Hydration
For immutable models, cache from() results:
$cache = new \Symfony\Component\Cache\Adapter\FilesystemAdapter();
$user = $cache->get('user_' . md5($data), fn() => User::from($data));
Avoid Reflection Overhead Pre-compile attributes in a static property:
private static ?array $descriptions;
public static function from(array $data): static {
if (self::$descriptions === null) {
self::$descriptions = self::getDescriptions();
}
// ... rest of hydration
}
Custom Attributes
Extend Describe to add domain-specific keys:
#[Attribute(Attribute::TARGET_PROPERTY)]
class CustomDescribe extends \Zerotoprod\DataModel\Describe {
public function __construct(array $options = []) {
parent::__construct($options + ['custom_key' => 'value']);
}
}
Override Hydration
Subclass DataModel to modify behavior:
trait CustomDataModel extends \Zerotoprod\DataModel\DataModel {
protected static function resolveProperty(string $property, mixed $value, array $context): mixed {
// Custom logic
return parent::resolveProperty($property, $value, $context);
}
}
Integrate with Laravel
DataModel to Laravel’s container:
$this->app->bind(User::class, function () {
return User::from(request()->input());
});
DataModel for validation:
public function rules() {
return [
'name' => 'required|string',
// Hydrate validated data
'user' => [new User(), 'from' => request()->validated()]
];
}
Custom Instantiation
Use via to control object creation:
#[Describe(['via' => [self::class, 'createFromArray']])]
public Address $shippingAddress;
public static function createFromArray(array $data): Address {
return new Address($data['street'], $data['city']);
}
Eloquent Models
DataModel with Eloquent’s $fillable. Use DataModel for DTOs instead.UserDto class for API responses.Validation Rules
$validator = Validator::make($data, [
'name' => 'required|string|max:255',
]);
if ($validator->fails()) {
throw new \RuntimeException('Validation failed');
}
$user = User::from($data);
API Resources
DataModel to transform collections:
public function toArray($request) {
return User::from($this->resource->toArray())->toArray();
}
php artisan vendor:publish --provider="Zerotoprod\DataModel\DataModelServiceProvider"
Key options:
'strict_mode' => false
How can I help you explore Laravel packages today?