baldie81 / json-marshaler
Attribute-based JSON marshalling/unmarshalling for PHP 8.2+ with built-in validation
Requires
- php: ^8.2
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
This package is auto-updated.
Last update: 2026-03-16 15:36:59 UTC
README
Attribute-based JSON marshalling/unmarshalling for PHP 8.2+ with built-in validation.
Inspired by C# records and Go's encoding/json marshaler. PHP doesn't have records, but since 8.2 we have readonly classes — and with constructor promoted properties (available since 8.0), we can emulate the same concise, immutable data structures that make marshalling feel natural.
Why use it?
- Typed deserialization — go from raw JSON to fully typed object graphs in one call, no manual array access
- Declarative — define your structure once with attributes; the marshaler handles the rest
- Validated at the boundary — bad data throws before it ever reaches your business logic
- Immutable by design — readonly classes guarantee your data objects can't be mutated after construction
- Great for API responses — parse incoming webhook payloads, third-party API responses, or your own REST/GraphQL endpoints into type-safe objects instead of passing associative arrays around your codebase
Installation
composer require baldie81/json-marshaler
Requires PHP 8.2 or higher.
Quick Start
Define a readonly class with promoted properties — the PHP equivalent of a record:
use Baldie81\JsonMarshaler\JsonMarshal; readonly class User { public function __construct( public int $id, public string $name, public string $email, ) {} }
Marshal to JSON:
$user = new User(1, 'Jane Doe', 'jane@example.com'); echo JsonMarshal::to($user);
{
"id": 1,
"name": "Jane Doe",
"email": "jane@example.com"
}
Unmarshal from JSON:
$json = '{"id": 1, "name": "Jane Doe", "email": "jane@example.com"}'; $user = JsonMarshal::from($json, User::class); echo $user->name; // "Jane Doe"
Custom JSON Keys
Use #[JsonProperty] to map a property to a different JSON key — just like Go's json:"field_name" struct tags or C#'s [JsonPropertyName]:
use Baldie81\JsonMarshaler\Attributes\JsonProperty; readonly class Product { public function __construct( public int $id, #[JsonProperty('product_name')] public string $productName, #[JsonProperty('unit_price')] public float $unitPrice, ) {} }
$json = '{"id": 1, "product_name": "Widget", "unit_price": 9.99}'; $product = JsonMarshal::from($json, Product::class); echo $product->productName; // "Widget" echo JsonMarshal::to($product); // {"id": 1, "product_name": "Widget", "unit_price": 9.99}
Omit Empty
Use omitEmpty: true on #[JsonProperty] to exclude properties from the JSON output when their value is null, an empty string, or an empty array — similar to Go's omitempty tag. Zero values like 0, 0.0, and false are not considered empty.
readonly class Profile { public function __construct( #[JsonProperty('first_name')] public string $firstName, #[JsonProperty('middle_name', omitEmpty: true)] public ?string $middleName = null, #[JsonProperty('tags', omitEmpty: true)] public array $tags = [], ) {} }
$profile = new Profile(firstName: 'Alice'); echo JsonMarshal::to($profile); // {"first_name": "Alice"} // — middle_name and tags are omitted $profile = new Profile(firstName: 'Alice', middleName: 'B', tags: ['admin']); echo JsonMarshal::to($profile); // {"first_name": "Alice", "middle_name": "B", "tags": ["admin"]}
Sensitive Fields
Use sensitive: true on #[JsonProperty] to mask a property's value with **** during marshalling. The actual value is preserved in the object — only the JSON output is masked:
readonly class Credentials { public function __construct( #[JsonProperty('username')] public string $username, #[JsonProperty('password', sensitive: true)] public string $password, ) {} }
$creds = new Credentials(username: 'admin', password: 's3cret!'); echo JsonMarshal::to($creds); // {"username": "admin", "password": "****"} echo $creds->password; // "s3cret!" — original value is untouched
Nested Objects
Nested objects are resolved automatically via type hints — no extra configuration needed:
readonly class Address { public function __construct( public string $street, public string $city, #[JsonProperty('zip_code')] public string $zipCode, ) {} } readonly class Customer { public function __construct( public int $id, public string $name, public Address $address, ) {} }
$json = '{ "id": 1, "name": "Jane Doe", "address": { "street": "123 Main St", "city": "Springfield", "zip_code": "62704" } }'; $customer = JsonMarshal::from($json, Customer::class); echo $customer->address->city; // "Springfield"
Typed Collections
Use #[JsonList] to unmarshal arrays of objects:
use Baldie81\JsonMarshaler\Attributes\JsonList; readonly class OrderItem { public function __construct( #[JsonProperty('product_name')] public string $productName, public int $quantity, public float $price, ) {} } readonly class Order { public function __construct( public int $id, #[JsonList(OrderItem::class)] public array $items, ) {} }
$json = '{ "id": 42, "items": [ {"product_name": "Widget", "quantity": 2, "price": 9.99}, {"product_name": "Gadget", "quantity": 1, "price": 24.99} ] }'; $order = JsonMarshal::from($json, Order::class); echo $order->items[0]->productName; // "Widget"
SelfHydrating Trait
Add fromJson() and toJson() directly to your class:
use Baldie81\JsonMarshaler\Traits\SelfHydrating; readonly class User { use SelfHydrating; public function __construct( public int $id, public string $name, public string $email, ) {} }
$user = User::fromJson('{"id": 1, "name": "Jane", "email": "jane@example.com"}'); echo $user->toJson();
Validation
Validators are applied as attributes on properties and run automatically during unmarshalling. Stack multiple validators on a single property:
use Baldie81\JsonMarshaler\Validators\{NotEmpty, MinLength, MaxLength, Pattern, Range, Url, InList}; readonly class Product { public function __construct( #[NotEmpty] public string $name, #[MinLength(3)] #[MaxLength(20)] #[Pattern('/^[A-Z0-9\-]+$/')] public string $sku, #[Range(min: 0.01, max: 99999.99)] public float $price, #[Url] public string $website, #[InList('active', 'draft', 'archived')] public string $status, ) {} }
Invalid data throws an InvalidArgumentException:
$json = '{"name": "", "sku": "AB", "price": 10, "website": "https://example.com", "status": "active"}'; JsonMarshal::from($json, Product::class); // InvalidArgumentException: Field 'name' must not be empty.
Built-in Validators
| Validator | Description |
|---|---|
#[NotEmpty] |
Rejects null, empty strings, and empty arrays |
#[Email] |
Valid email address |
#[Url] |
Valid URL |
#[MinLength(n)] |
Minimum string length |
#[MaxLength(n)] |
Maximum string length |
#[Pattern('/regex/')] |
Matches a regular expression |
#[Range(min, max)] |
Numeric value within a range |
#[Positive] |
Positive number (int or float) |
#[PositiveInteger] |
Positive integer |
#[IsInteger] |
Must be an integer |
#[InList('a', 'b', ...)] |
Value must be one of the listed strings |
Custom Validators
Implement ValidatorInterface to create your own:
use Baldie81\JsonMarshaler\Contracts\ValidatorInterface; use Attribute; #[Attribute(Attribute::TARGET_PROPERTY)] readonly class Lowercase implements ValidatorInterface { public function isValid(mixed $value): bool { return is_string($value) && $value === strtolower($value); } public function getErrorMessage(string $field): string { return "Field '{$field}' must be lowercase."; } }
Then use it like any built-in validator:
readonly class Tag { public function __construct( #[Lowercase] public string $slug, ) {} }
Working with API Responses
JsonMarshaler is a natural fit for consuming JSON APIs. Instead of navigating nested associative arrays, unmarshal the response directly into typed objects:
readonly class Emoji { use SelfHydrating; public function __construct( #[NotEmpty] public string $name, public string $category, public string $group, public array $htmlCode, public array $unicode, ) {} }
// Consuming a real API — https://emojihub.yurace.pro/api/random $response = file_get_contents('https://emojihub.yurace.pro/api/random'); $emoji = Emoji::fromJson($response); echo $emoji->name; // "old man, type-5" echo $emoji->category; // "smileys and people" echo $emoji->group; // "person" echo $emoji->htmlCode[0]; // "👴"
The same approach works for outgoing responses — marshal your objects directly in a controller:
// In a Laravel/Symfony/Slim controller $customer = Customer::fromJson($request->getContent()); // validated on the way in // ... business logic ... return new JsonResponse( json_decode(JsonMarshal::to($customer), true), 200 );
Full Example
Putting it all together — a record-like readonly class with nested objects, typed collections, validation, and self-hydration:
use Baldie81\JsonMarshaler\Attributes\{JsonProperty, JsonList}; use Baldie81\JsonMarshaler\Traits\SelfHydrating; use Baldie81\JsonMarshaler\Validators\{Email, NotEmpty, Range}; readonly class Address { public function __construct( #[NotEmpty] public string $street, #[NotEmpty] public string $city, #[JsonProperty('zip_code')] public string $zipCode, ) {} } readonly class OrderItem { public function __construct( #[JsonProperty('product_name')] #[NotEmpty] public string $productName, #[Range(min: 1, max: 1000)] public int $quantity, public float $price, ) {} } readonly class Customer { use SelfHydrating; public function __construct( public int $id, #[JsonProperty('full_name')] #[NotEmpty] public string $fullName, #[Email] public string $email, public Address $address, #[JsonList(OrderItem::class)] public array $orders = [], ) {} }
$json = '{ "id": 1, "full_name": "Jane Doe", "email": "jane@example.com", "address": { "street": "123 Main St", "city": "Springfield", "zip_code": "62704" }, "orders": [ {"product_name": "Widget", "quantity": 3, "price": 9.99}, {"product_name": "Gadget", "quantity": 1, "price": 24.99} ] }'; $customer = Customer::fromJson($json); echo $customer->fullName; // "Jane Doe" echo $customer->address->city; // "Springfield" echo $customer->orders[0]->productName; // "Widget" echo $customer->toJson(); // Back to JSON