baldie81/json-marshaler

Attribute-based JSON marshalling/unmarshalling for PHP 8.2+ with built-in validation

Maintainers

Package info

github.com/baldie81/json-marshaler

pkg:composer/baldie81/json-marshaler

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.1.0 2026-02-16 15:18 UTC

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

License

MIT