Serde
| use Crell\Serde\SerdeCommon; |
| |
| $serde = new SerdeCommon(); |
| |
| $object = new SomeClass('a', 'b', new OtherClass()); |
| |
| $json = $serde->serialize($object, format: 'json'); |
| |
| $obj = $serde->deserialize($json, from: 'json', to: SomeClass::class); |
- JSON, YAML, TOML, array, CSV, streaming JSON, streaming CSV
- Customize per-object-type
- Support any additional formats
- Will use
__serialize()/__unserialize() if defined
Basic usage
| class Person |
| { |
| #[Field(serializedName: 'callme')] |
| public string $firstName = 'Larry'; |
| |
| #[Field(renameWith: Cases::CamelCase)] |
| public string $lastName = 'Garfield'; |
| |
| public string $job = 'Presenter'; |
| |
| #[Field(alias: ['company'])] |
| public string $employer = 'MakersHub'; |
| |
| #[Field(exclude: true)] |
| public string $password = 'youwish'; |
| } |
| { |
| "callme": "Larry", |
| "LastName": "Garfield", |
| "job": "Presenter", |
| "employer": "MakersHub" |
| } |
Cool PHP trick #3
Enums as default objects
| interface RenamingStrategy { |
| public function convert(string $name): string; |
| } |
| |
| enum Cases implements RenamingStrategy { |
| case UPPERCASE; |
| case lowercase; |
| case snake_case; |
| case kebab_case; |
| case CamelCase; |
| case lowerCamelCase; |
| |
| public function convert(string $name): string { |
| return match ($this) { |
| self::UPPERCASE => strtoupper($name), |
| self::lowercase => strtolower($name), |
| self::snake_case => |
| self::kebab_case => |
| self::CamelCase => |
| self::lowerCamelCase => |
| }; |
| } |
Default handling (deserialization)
| class Person |
| { |
| #[Field(default: 'Hidden')] |
| public string $location; |
| |
| #[Field[(useDefault: false)] |
| public int $age; |
| |
| #[Field(requireValue: true)] |
| public string $job; |
| |
| public function __construct( |
| public string $name = 'Anonymous', |
| ) {} |
| } |
location -> "Hidden"
name -> "Anonymous"
age -> uninitialized
job -> Exception
Sequences vs Dictionaries
| class Order { |
| public string $orderId; |
| |
| public int $userId; |
| |
| #[Field(serializedName: 'items')] |
| #[SequenceField(arrayType: Product::class)] |
| public array $products; |
| |
| #[DictionaryField(arrayType: Tag::class, keyType: KeyType::String)] |
| public array $tags; |
| } |
| { |
| "orderId": "abc123", |
| "userId": 5, |
| "items": [ |
| { "name": "Widget", "price": 9.99 }, |
| { "name": "Gadget", "price": 4.99 } |
| ], |
| "tags": { |
| "userClass": {"name": "VIP"}, |
| "discount": {"name": "Closeout"} |
| } |
| } |
Implosion
| class Order { |
| #[SequenceField(implodeOn: ',')] |
| protected array $productIds = [5, 6, 7]; |
| |
| #[DictionaryField(implodeOn: ',', joinOn: '=')] |
| protected array $dimensions = [ |
| 'height' => 40, |
| 'width' => 20, |
| ]; |
| } |
| { |
| "productIds": "5,6,7", |
| "dimensions": "height=40,width=20" |
| } |
Flatten/collect
| class Results { |
| public function __construct( |
| #[Serde\Field(flatten: true)] |
| public Pagination $pagination, |
| #[Serde\SequenceField(arrayType: Product::class)] |
| public array $products, |
| ) {} |
| } |
| |
| class Pagination { |
| public function __construct(public int $total, public int $offset, public int $limit) {} |
| } |
| |
| class Product { |
| public function __construct(public string $name, public float $price) {} |
| } |
| class Results { |
| public function __construct( |
| #[Serde\Field(flatten: true)] |
| public Pagination $pagination, |
| #[Serde\SequenceField(arrayType: Product::class)] |
| public array $products, |
| ) {} |
| } |
| |
| class Pagination { |
| public function __construct(public int $total, public int $offset, public int $limit) {} |
| } |
| |
| class Product { |
| public function __construct(public string $name, public float $price) {} |
| } |
| { |
| "total": 100, |
| "offset": 20, |
| "limit": 10, |
| "products": [ |
| { "name": "Widget", "price": 9.99 }, |
| { "name": "Gadget", "price": 4.99 } |
| ] |
| } |
Advanced flattening
| class DetailedResults { |
| public function __construct( |
| #[Serde\Field(flatten: true)] |
| public NestedPagination $pagination, |
| #[Serde\Field(flatten: true)] |
| public ProductType $type, |
| #[Serde\SequenceField(arrayType: Product::class)] |
| public array $products, |
| #[Serde\Field(flatten: true)] |
| public array $other = [], |
| ) {} |
| } |
| class NestedPagination { |
| public function __construct( |
| public int $total, |
| public int $limit, |
| #[Serde\Field(flatten: true)] |
| public PaginationState $state, |
| ) {} |
| } |
| class DetailedResults { |
| public function __construct( |
| #[Serde\Field(flatten: true)] |
| public NestedPagination $pagination, |
| #[Serde\Field(flatten: true)] |
| public ProductType $type, |
| #[Serde\SequenceField(arrayType: Product::class)] |
| public array $products, |
| #[Serde\Field(flatten: true)] |
| public array $other = [], |
| ) {} |
| } |
| class NestedPagination { |
| public function __construct( |
| public int $total, |
| public int $limit, |
| #[Serde\Field(flatten: true)] |
| public PaginationState $state, |
| ) {} |
| } |
| class DetailedResults { |
| public function __construct( |
| #[Serde\Field(flatten: true)] |
| public NestedPagination $pagination, |
| #[Serde\Field(flatten: true)] |
| public ProductType $type, |
| #[Serde\SequenceField(arrayType: Product::class)] |
| public array $products, |
| #[Serde\Field(flatten: true)] |
| public array $other = [], |
| ) {} |
| } |
| class NestedPagination { |
| public function __construct( |
| public int $total, |
| public int $limit, |
| #[Serde\Field(flatten: true)] |
| public PaginationState $state, |
| ) {} |
| } |
| class DetailedResults { |
| public function __construct( |
| #[Serde\Field(flatten: true)] |
| public NestedPagination $pagination, |
| #[Serde\Field(flatten: true)] |
| public ProductType $type, |
| #[Serde\SequenceField(arrayType: Product::class)] |
| public array $products, |
| #[Serde\Field(flatten: true)] |
| public array $other = [], |
| ) {} |
| } |
| class NestedPagination { |
| public function __construct( |
| public int $total, |
| public int $limit, |
| #[Serde\Field(flatten: true)] |
| public PaginationState $state, |
| ) {} |
| } |
| class PaginationState { |
| public function __construct(public int $offset) {} |
| } |
| class ProductType { |
| public function __construct(public string $name = '', public string $category = '') {} |
| } |
Advanced flattening
| { |
| "total": 100, |
| "limit": 10, |
| "offset": 20, |
| "name": "Dodads", |
| "category": "Small items", |
| "products": [ |
| { |
| "name": "Widget", |
| "price": 9.99 |
| }, |
| { |
| "name": "Gadget", |
| "price": 4.99 |
| } |
| ], |
| "foo": "beep", |
| "bar": "boop" |
| } |
| { |
| "total": 100, |
| "limit": 10, |
| "offset": 20, |
| "name": "Dodads", |
| "category": "Small items", |
| "products": [ |
| { |
| "name": "Widget", |
| "price": 9.99 |
| }, |
| { |
| "name": "Gadget", |
| "price": 4.99 |
| } |
| ], |
| "foo": "beep", |
| "bar": "boop" |
| } |
| { |
| "total": 100, |
| "limit": 10, |
| "offset": 20, |
| "name": "Dodads", |
| "category": "Small items", |
| "products": [ |
| { |
| "name": "Widget", |
| "price": 9.99 |
| }, |
| { |
| "name": "Gadget", |
| "price": 4.99 |
| } |
| ], |
| "foo": "beep", |
| "bar": "boop" |
| } |
| { |
| "total": 100, |
| "limit": 10, |
| "offset": 20, |
| "name": "Dodads", |
| "category": "Small items", |
| "products": [ |
| { |
| "name": "Widget", |
| "price": 9.99 |
| }, |
| { |
| "name": "Gadget", |
| "price": 4.99 |
| } |
| ], |
| "foo": "beep", |
| "bar": "boop" |
| } |
| { |
| "total": 100, |
| "limit": 10, |
| "offset": 20, |
| "name": "Dodads", |
| "category": "Small items", |
| "products": [ |
| { |
| "name": "Widget", |
| "price": 9.99 |
| }, |
| { |
| "name": "Gadget", |
| "price": 4.99 |
| } |
| ], |
| "foo": "beep", |
| "bar": "boop" |
| } |
Type Maps
| interface Product {} |
| |
| interface Book extends Product {} |
| |
| class PaperBook implements Book { |
| protected string $title; |
| protected int $pages; |
| } |
| |
| class DigitalBook implements Book { |
| protected string $title; |
| protected int $bytes; |
| } |
| |
| class Sale { |
| protected Book $book; |
| protected float $discountRate; |
| } |
| |
| class Order { |
| protected string $orderId; |
| |
| #[SequenceField(arrayType: Book::class)] |
| protected array $products; |
| } |
Type Maps
| class Sale { |
| #[ClassNameTypeMap(key: 'type')] |
| protected Book $book; |
| |
| protected float $discountRate; |
| } |
| { |
| "book": { |
| "type": "Your\\App\\DigitalBook", |
| "title": "Thinking Functionally in PHP", |
| "bytes": 45000 |
| }, |
| "discountRate": 0.2 |
| } |
Type Maps
| class Sale { |
| #[StaticTypeMap(key: 'type', map: [ |
| 'paper' => PaperBook::class, |
| 'ebook' => DigitalBook::class, |
| ])] |
| protected Book $book; |
| |
| protected float $discountRate; |
| } |
| #[StaticTypeMap(key: 'type', map: [ |
| 'paper' => Book::class, |
| 'ebook' => DigitalBook::class, |
| ])] |
| interface Book {} |
| { |
| "book": { |
| "type": "ebook", |
| "title": "Thinking Functionally in PHP", |
| "bytes": 45000 |
| }, |
| "discountRate": 0.2 |
| } |
Dynamic Type Maps
| class ProductTypeMap implements TypeMap { |
| public function __construct(protected readonly Connection $db) {} |
| |
| public function keyField(): string { |
| return 'type'; |
| } |
| |
| public function findClass(string $id): ?string { |
| return $this->db->someLookup($id); |
| } |
| |
| public function findIdentifier(string $class): ?string { |
| return $this->db->someMappingLogic($class); |
| } |
| } |
| |
| $typeMap = new ProductTypeMap($dbConnection); |
| |
| $serde = new SerdeCommon(typeMaps: [ |
| Your\App\Product::class => $typeMap, |
| ]); |
| |
| $json = $serde->serialize($aBook, to: 'json'); |
| class ProductTypeMap implements TypeMap { |
| public function __construct(protected readonly Connection $db) {} |
| |
| public function keyField(): string { |
| return 'type'; |
| } |
| |
| public function findClass(string $id): ?string { |
| return $this->db->someLookup($id); |
| } |
| |
| public function findIdentifier(string $class): ?string { |
| return $this->db->someMappingLogic($class); |
| } |
| } |
| |
| $typeMap = new ProductTypeMap($dbConnection); |
| |
| $serde = new SerdeCommon(typeMaps: [ |
| Your\App\Product::class => $typeMap, |
| ]); |
| |
| $json = $serde->serialize($aBook, to: 'json'); |
| class ProductTypeMap implements TypeMap { |
| public function __construct(protected readonly Connection $db) {} |
| |
| public function keyField(): string { |
| return 'type'; |
| } |
| |
| public function findClass(string $id): ?string { |
| return $this->db->someLookup($id); |
| } |
| |
| public function findIdentifier(string $class): ?string { |
| return $this->db->someMappingLogic($class); |
| } |
| } |
| |
| $typeMap = new ProductTypeMap($dbConnection); |
| |
| $serde = new SerdeCommon(typeMaps: [ |
| Your\App\Product::class => $typeMap, |
| ]); |
| |
| $json = $serde->serialize($aBook, to: 'json'); |
Streaming
- Can stream to JSON or CSV
\Traversable objects treated as any other object
iterable will get "run out" when serializing
- Result: Lazy create and lazy stream at once!
Streaming
| |
| $s = new SerdeCommon(formatters: [new CsvStreamFormatter()]); |
| |
| |
| |
| $init = new FormatterStream(fopen('/tmp/output.json', 'wb')); |
| |
| $result = $serde->serialize($data, format: 'csv-stream', init: $init); |
| |
| $fp = $result->stream; |
| |
Streaming
| class ProductList { |
| public function __construct( |
| #[SequenceField(arrayType: Product::class)] |
| private iterable $products, |
| ) {} |
| } |
| class Product { } |
| $db = ...; |
| |
| $callback = function() use ($db) { |
| $result = $db->query("SELECT name, color, price FROM products ORDER BY name"); |
| |
| foreach ($result as $record) { |
| $sales = $db->query("SELECT start, end FROM sales WHERE product=?", $record['id'])->fetchAll(); |
| yield new Product($record, $sales); |
| } |
| }; |
| |
| $products = new ProductList($callback()); |
| |
| $s = new SerdeCommon(formatters: [new JsonStreamFormatter()]); |
| |
| |
| $init = new FormatterStream(fopen('php://output', 'wb')); |
| $result = $serde->serialize($products, format: 'json-stream', init: $init); |
Scopes
| class User { |
| private string $username; |
| |
| #[Field(exclude: true)] |
| private string $password; |
| |
| #[Field(exclude: true)] |
| #[Field(scope: 'admin')] |
| private string $role; |
| } |
| $json = $serde->serialize($user, 'json'); |
| |
| |
| |
| $json = $serde->serialize($user, 'json', scopes: ['admin']); |
| |
| |
| |
Versioning with scopes
| #[ClassSettings(includeFieldsByDefault: false)] |
| class Product { |
| #[Field] |
| private int $id = 5; |
| |
| private int $stock = 50; |
| |
| #[Field, Field(scopes: ['legacy'], serializedName: 'label')] |
| private string $name = 'Fancy widget'; |
| |
| #[Field(scopes: ['newsystem'])] |
| private string $price = '9.99'; |
| |
| #[Field(scopes: ['legacy'], serializedName: 'cost')] |
| private float $legacyPrice = 9.99; |
| } |
| |
| { "id": 5, "name": "Fancy widget" } |
| |
| { "id": 5, "label": "Fancy widget", "cost": 9.99 } |
| |
| { "id": 5, "name": "Fancy widget", "price": "9.99" } |
Full circle
TYPO3 decided they liked global arrays
Crell/Config
Overview
| |
| color: "#ccddee" |
| bgcolor: "#ffffff" |
| class EditorSettings { |
| public function __construct( |
| public readonly string $color, |
| public readonly string $bgcolor, |
| public readonly int $fontSize = 14, |
| ) {} |
| } |
| |
| $loader = new LayeredLoader([ |
| new YamlFileSource('./config/common'), |
| new YamlFileSource('./config/' . APP_ENV), |
| ]); |
| |
| $cachedLoader = new SerializedFilesytemCache($loader, '/cache/path'); |
| $editorConfig = $cachedLoader->load(EditorSettings::class); |
Advanced usage
| use Crell\Config\Config; |
| |
| #[Config('dashboard')] |
| readonly class DashboardSettings { |
| public function __construct( |
| public string $name, |
| #[Field(flatten: true)] |
| #[DictionaryField(arrayType: Component::class, keyType: KeyType::String)] |
| #[StaticTypeMap(key: 'type', map: [ |
| 'latest_posts' => LatestPosts::class, |
| 'user_status' => UserStatus::class, |
| 'pending' => PostsNeedModeration::class, |
| ])] |
| public array $components = [], |
| ) {} |
| } |
| use Crell\Config\Config; |
| |
| #[Config('dashboard')] |
| readonly class DashboardSettings { |
| public function __construct( |
| public string $name, |
| #[Field(flatten: true)] |
| #[DictionaryField(arrayType: Component::class, keyType: KeyType::String)] |
| #[StaticTypeMap(key: 'type', map: [ |
| 'latest_posts' => LatestPosts::class, |
| 'user_status' => UserStatus::class, |
| 'pending' => PostsNeedModeration::class, |
| ])] |
| public array $components = [], |
| ) {} |
| } |
| use Crell\Config\Config; |
| |
| #[Config('dashboard')] |
| readonly class DashboardSettings { |
| public function __construct( |
| public string $name, |
| #[Field(flatten: true)] |
| #[DictionaryField(arrayType: Component::class, keyType: KeyType::String)] |
| #[StaticTypeMap(key: 'type', map: [ |
| 'latest_posts' => LatestPosts::class, |
| 'user_status' => UserStatus::class, |
| 'pending' => PostsNeedModeration::class, |
| ])] |
| public array $components = [], |
| ) {} |
| } |
| use Crell\Config\Config; |
| |
| #[Config('dashboard')] |
| readonly class DashboardSettings { |
| public function __construct( |
| public string $name, |
| #[Field(flatten: true)] |
| #[DictionaryField(arrayType: Component::class, keyType: KeyType::String)] |
| #[StaticTypeMap(key: 'type', map: [ |
| 'latest_posts' => LatestPosts::class, |
| 'user_status' => UserStatus::class, |
| 'pending' => PostsNeedModeration::class, |
| ])] |
| public array $components = [], |
| ) {} |
| } |
| use Crell\Config\Config; |
| |
| #[Config('dashboard')] |
| readonly class DashboardSettings { |
| public function __construct( |
| public string $name, |
| #[Field(flatten: true)] |
| #[DictionaryField(arrayType: Component::class, keyType: KeyType::String)] |
| #[StaticTypeMap(key: 'type', map: [ |
| 'latest_posts' => LatestPosts::class, |
| 'user_status' => UserStatus::class, |
| 'pending' => PostsNeedModeration::class, |
| ])] |
| public array $components = [], |
| ) {} |
| } |
Now looks for dashboard.[yaml|json|php|ini]
Default is str_replace($class, '\', '_')
Advanced usage
| readonly class LatestPosts implements Component { |
| public function __construct( |
| public string $category, |
| public Side $side = Side::Left, |
| ) {} |
| } |
| |
| readonly class PostsNeedModeration implements Component { |
| public function __construct( |
| public int $count = 5, |
| public Side $side = Side::Left, |
| ) {} |
| } |
| |
| readonly class UserStatus implements Component |
| { |
| public function __construct( |
| public string $user, |
| public Side $side = Side::Left, |
| ) {} |
| } |
| enum Side: string { |
| case Left = 'left'; |
| case Right = 'right'; |
| } |
The config
| |
| name: "User dashboard" |
| me: |
| type: 'user_status' |
| movie_talk: |
| type: 'latest_posts' |
| category: movies |
| music_talk: |
| type: 'latest_posts' |
| category: music |
| side: right |
| |
| name: "Admin dashboard" |
| mod_todo: |
| type: 'pending' |
| side: right |
| $loaders = [ |
| new YamlFileSource('./config/common'), |
| new YamlFileSource('./config/' . APP_ENV), |
| ]; |
| if (user_is_admin()) $loaders[] = new YamlFileSource('./config/admin'); |
| $loader = new LayeredLoader($loaders); |
| |
| $dashConfig = $loader->load(DashboardSettings::class); |
Use it
| class Dashboard { |
| public function __construct(private DashboardSettings $settings) {} |
| |
| public function renderDashboard(): string { |
| |
| $this->settings->name; |
| foreach ($this->settings->components as $c) { ... } |
| } |
| } |
Test it
| class DashboardTest extends TestCase { |
| public function test_something(): void { |
| $settings = new DashboardSettings('Test', [new UserStatus('crell')]); |
| $subject = new Dashboard($settings); |
| |
| } |
| } |
Dependency Inject it
| $container->register(DashboardSettings::class, fn(Container $c) |
| => $c->get(ConfigLoader::class)->load(DashboardSettings::class); |