If you’ve worked in a lot of codebases, this scenario will be familiar: somewhere in the app, we’re JSON-decoding a string, then using that to pass arguments to a method (such as a constructor). It may look something like this:
1 2 3 4 |
$json = '{"first": "Steve", "last": "Grunwell"}'; $data = json_decode($json); $person = new Person($data->first, $data->last); |
I’d like to humbly ask that you stop doing this. Instead, this post is going to show you how to accomplish the same result with a static, factory method and explain why the latter approach will save you all sorts of headaches.
What’s wrong with JSON-decoding?
JSON is a wonderful format, but it does not offer type-safety, nor can we require that certain keys are present. This isn’t JSON’s fault, of course: its role is to represent data, not ensure that it’s valid.
In our example from earlier, we’re assuming that the JSON object—wherever it might come from—will always have both “first” and “last” keys, but what happens if the data doesn’t match our expectations?
1 2 3 4 |
$json = '{"first": "Cher"}'; $data = json_decode($json); $person = new Person($data->first, $data->last); |
This will trigger an “Undefined property: stdClass::$last” warning, which…isn’t great.
Additionally, if our Person
model expects both a first and last name (which is rather presumptuous), we might get a fatal TypeError
like “Person::__construct(): Argument #2 ($last) must be of type string, null given”.
What if both keys are present, but the values are not strings? Or maybe we’re given empty strings—this probably isn’t what your application is expecting.
Factory methods for self-validating JSON-decoding
This is an excellent use-case for a so-called “factory method” on our class, which might look something like this:
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 |
final class Person { public function __construct( public readonly string $first, public readonly string $last ) { } /** * Construct a Person from a JSON string. */ public static function fromJson(string $json): self { $data = json_decode($json); if (empty($data->first)) { throw new \ValueError('First name cannot be empty!'); } elseif (empty($data->last)) { throw new \ValueError('Last name cannot be empty!'); } return new self( (string) $data->first, (string) $data->last ); } } |
Now, any time we’re receiving a JSON string like in previous examples, we’ll just pass the string directly into Person::fromJson()
: if either the first or last names are missing, we’ll throw a ValueError
exception. If we’re given non-string (but non-empty) values, we’re explicitly casting them to strings to satisfy the object constructor.
If we decided to add more fields that may or may not be present (e.g. middle name), we could also use null coalescing to provide reasonable defaults.
The important thing here is that the Person
class is now in control of how the JSON data is handled, rather than sticking it in a controller or unrelated method. We use the factory method to build (get it?) the object.
Testing the factory method
As a bonus, it now becomes very easy to test our handling of JSON strings within the factory method(s):
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 |
use PHPUnit\Framework\TestCase; final class PersonTest extends TestCase { public function testFromJson(): void { $json = '{"first": "Steve", "last": "Grunwell"}'; $person = Person::fromJson($json); $this->assertSame('Steve', $person->first); $this->assertSame('Grunwell', $person->last); } public function testFromJsonWithMissingFirstName(): void { $this->expectException(\ValueError::class); Person::fromJson('{"last": "Awkwafina"}'); } public function testFromJsonWithMissingLastName(): void { $this->expectException(\ValueError::class); Person::fromJson('{"first": "Madonna"}'); } public function testFromJsonWithNonStringNames(): void { $person = Person::fromJson('{"first": 123, "last": 4.56}'); $this->assertSame('123', $person->first); $this->assertSame('45.6', $person->last); } } |
With those tests, we can be sure that any place we’re calling Person::fromJson()
we’re handling things as expected.
Factory methods and value objects
If you’ve read my article The Beauty of PHP Value Objects (or attended the talk of the same name at Cascadia PHP 2024), you’re probably thinking “wait, but wouldn’t we be validating things twice if we’re adding factory methods like this to value object classes?”
If your class constructor is already doing the hard work of validating the data, the factory method only has to worry about two things:
- Decoding the JSON
- Ensuring that all required arguments are present (and in the correct type)
Using our example from earlier, we no longer have to worry about checking that “first” and “last” are not empty, and our factory method can be reduced to this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
final class Person { // ... /** * Construct a Person from a JSON string. */ public static function fromJson(string $json): self { $data = json_decode($json); return new self( (string) ($data->first ?? ''), (string) ($data->last ?? '') ); } } |
In this form, we’re simply ensuring that strings are passed to the constructor arguments, using the null coalesce operator (??
) to add reasonable defaults if one or both keys are missing.
Leave a Reply