Symfony includes two components dedicated to working with JSON:

  • JsonStreamer encodes PHP data into JSON and decodes JSON back into PHP objects by streaming the contents, which provides high performance and low memory usage even for large payloads;
  • JsonPath queries and extracts values from JSON documents using the standard JSONPath syntax.

Symfony 8.1 improves both components in several ways.

Handling Value Objects

Mathias Arlaud
Contributed by Mathias Arlaud in #63339

By default, JsonStreamer serializes an object by traversing its properties one by one. However, some objects are better represented as a single scalar value than as a nested JSON object. Think of a Money object encoded as "100 EUR" or a temperature represented as a number.

Symfony 8.1 introduces a generic mechanism for this called value objects. Implement the new ValueObjectTransformerInterface to define how an object maps to and from a scalar value:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/Transformer/MoneyValueObjectTransformer.php
namespace App\Transformer;

use App\ValueObject\Money;
use Symfony\Component\JsonStreamer\Transformer\ValueObjectTransformerInterface;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\BuiltinType;

/**
 * @implements ValueObjectTransformerInterface<Money, string>
 */
class MoneyValueObjectTransformer implements ValueObjectTransformerInterface
{
    public function transform(object $object, array $options = []): int|float|string|bool|null
    {
        return $object->amount.' '.$object->currency;
    }

    public function reverseTransform(int|float|string|bool|null $scalar, array $options = []): object
    {
        [$amount, $currency] = explode(' ', $scalar);

        return new Money((int) $amount, $currency);
    }

    public static function getStreamValueType(): BuiltinType
    {
        return Type::string();
    }

    public static function getValueObjectClassName(): string
    {
        return Money::class;
    }
}

Symfony auto-registers these transformers, and the generated code then calls the transformer instead of traversing the object's properties:

1
2
3
4
5
// write: "100 EUR"
$json = $jsonStreamWriter->write(new Money(100, 'EUR'), Type::object(Money::class));

// read: "100 EUR" -> Money(100, 'EUR')
$money = $jsonStreamReader->read('"100 EUR"', Type::object(Money::class));

Built-in DateInterval and DateTimeZone Value Objects

Mathias Arlaud
Contributed by Mathias Arlaud in #63742 and #63879

Building on the value object mechanism, Symfony 8.1 now handles DateInterval and DateTimeZone objects as value objects out of the box, following the same approach already used for DateTimeInterface.

DateInterval objects are serialized to ISO 8601 duration strings (e.g. P2Y6M1DT12H30M5S), and DateTimeZone objects use their name (e.g. "Europe/Paris" or "+02:00"). You can customize the duration format with the date_interval_format option:

1
2
3
4
5
use Symfony\Component\TypeInfo\Type;

$json = $jsonStreamWriter->write($task, Type::object(Task::class), [
    'date_interval_format' => 'P%yY%mM%dDT%hH%iM%sS',
]);

Configuring the DateTime Timezone

Mathias Arlaud
Contributed by Mathias Arlaud in #63735

When encoding or decoding DateTimeInterface objects, you can now convert the timezone by passing the date_time_timezone option as a string or a \DateTimeZone instance:

1
2
3
4
5
6
7
8
9
use Symfony\Component\TypeInfo\Type;

$json = $jsonStreamWriter->write($event, Type::object(Event::class), [
    'date_time_timezone' => 'Asia/Tokyo',
]);

$event = $jsonStreamReader->read($json, Type::object(Event::class), [
    'date_time_timezone' => new \DateTimeZone('America/Mexico_City'),
]);

Defining Default Options

Mathias Arlaud
Contributed by Mathias Arlaud in #62599

Repeating the same options on every write() and read() call is tedious. Symfony 8.1 lets you define default options for the entire application, so they are applied automatically to every operation:

1
2
3
4
5
6
# config/packages/framework.yaml
framework:
    json_streamer:
        default_options:
            # encode properties whose value is null
            include_null_properties: true

You can also define your own custom options here. Symfony passes them to your transformers, so you can read them inside transform() and reverseTransform():

1
2
3
4
5
# config/packages/framework.yaml
framework:
    json_streamer:
        default_options:
            my_custom_option: 'my_custom_value'

Custom JsonPath Functions

Alexandre Daubois
Contributed by Alexandre Daubois in #62823

The JsonPath component already supports the standard functions defined by RFC 9535 (length(), count(), match(), etc.). Symfony 8.1 lets you register your own functions and use them inside filter expressions.

Create an invokable class and apply the #[AsJsonPathFunction] attribute to it. Symfony registers the function automatically and makes it available in all JsonPath queries:

1
2
3
4
5
6
7
8
9
10
use Symfony\Component\JsonPath\Attribute\AsJsonPathFunction;

#[AsJsonPathFunction('upper')]
final class UppercaseFunction
{
    public function __invoke(mixed $value): ?string
    {
        return \is_string($value) ? strtoupper($value) : null;
    }
}

Inject the JsonPathCrawlerInterface to get a crawler pre-configured with all your custom functions, then use them in any expression:

1
2
3
$crawler = $crawlerFactory->crawl($json);

$result = $crawler->find('$.items[?upper(@.title) == "HELLO"]');

The number of arguments accepted by the function is inferred automatically from the __invoke() method signature. The optional returnType argument controls where the function can be used: a FunctionReturnType::Value function (the default) works in comparisons like the example above, while a FunctionReturnType::Logical function can be used as a standalone filter test such as $.items[?is_positive(@.value)].

Published in #Living on the edge