Codecs
New in version 1.17.
Overview
Codecs are used to decode BSON documents into PHP objects, and encode PHP objects into BSON documents. In contrast to other methods (e.g. type maps), codecs allow for greater customization and handling of different data types. They separate logic for BSON encoding and decoding from the domain classes, which also enables BSON to be decoded into plain old PHP objects.
Handling Documents
The main logic is contained in a document codec. This class implements the MongoDB\Codec\DocumentCodec
interface and
defines what data types can be encoded/decoded and how. The following example defines a Person
class and a codec to
transform it:
use MongoDB\BSON\ObjectId; final class Person { public function __construct( public string $name, public readonly ObjectId $id = new ObjectId(), ) { } }
use MongoDB\BSON\Document; use MongoDB\Codec\DecodeIfSupported; use MongoDB\Codec\DocumentCodec; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; /** @template-implements DocumentCodec<Person> */ final class PersonCodec implements DocumentCodec { // These traits define commonly used functionality to avoid duplication use DecodeIfSupported; use EncodeIfSupported; public function canDecode(mixed $value): bool { return $value instanceof Document && $value->has('name'); } public function canEncode(mixed $value): bool { return $value instanceof Person; } public function decode(mixed $value): Person { if (! $this->canDecode($value)) { throw UnsupportedValueException::invalidDecodableValue($value); } return new Person( $value->get('name'), $value->get('_id'), ); } public function encode(mixed $value): Document { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); } return Document::fromPHP([ '_id' => $value->id, 'name' => $value->name, ]); } }
To then use this codec with a collection, specify the codec
option when selecting the collection:
use MongoDB\Client; $client = new Client(); $collection = $client->selectCollection('test', 'person', [ 'codec' => new PersonCodec(), ]); $person = new Person('Jane Doe'); $collection->insertOne($person); $person = $collection->findOne();
The example above selects a collection and instructs it to use the PersonCodec
for encoding and decoding documents.
When inserting data, the PersonCodec
is used to encode the document. When retrieving data, the same PersonCodec
is used to decode BSON data into a Person
instance. Note that while the PersonCodec
could technically decode any
BSON document that contains a name field, we wouldn't want to use it for any other documents. Document codecs are meant
to be used with a MongoDB\Collection
, or when decoding embedded documents.
When using a collection with a codec, the codec will only accept and return data of that type for certain operations.
Insert and replace operations (e.g. insertOne
, `findOneAndReplace
, and some bulkWrite
operations) will
attempt to encode the given data using the provided codec. Trying to insert or replace a document that cannot be encoded
will result in an exception. Read operations (e.g. aggregate
, find
, and findOneAndUpdate
) will attempt to
decode returned documents using the provided codec. If the codec does not support the data returned, an exception will
be thrown.
You can disable codec usage for a specific operation or use a different codec (e.g. to decode the result of an
aggregation pipeline) by specifying null
for the codec
option for any operation. Alternatively, specifying a
type map using the typeMap
operation will also override the collection-level codec:
// Overrides the collection codec, falling back to the default type map $collection->aggregate($pipeline, ['codec' => null]); // Overrides the collection codec, using the specified type map $collection->findOne($filter, ['typeMap' => ['root' => 'stdClass']]);
Handling Fields and Data Types
The previous example showed how to define a codec for a specific class. However, you may want to create a codec that
handles a particular data type in any document. This can be achieved by implementing the MongoDB\Codec\Codec
interface.
The following example defines a codec that stores DateTimeInterface
instances as an embedded document containing a
BSON date and accompanying timezone string. Those same embedded documents can then be translated back into a
DateTimeImmutable
during BSON decoding.
use MongoDB\BSON\Document; use MongoDB\BSON\UTCDateTime; use MongoDB\Codec\Codec; use MongoDB\Codec\DecodeIfSupported; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; /** @template-implements Codec<Document, DateTimeImmutable> */ final class DateTimeCodec implements Codec { use DecodeIfSupported; use EncodeIfSupported; public function canDecode(mixed $value): bool { /* This codec inspects the BSON document to ensure it has the fields it expects, and that those fields are of * the correct type. This is a robust approach to avoid decoding document that are not supported and would cause * exceptions. * * For large documents, this can be inefficient as we're inspecting the entire document four times (once for * each call to has() and get()). For small documents, this is not a problem. */ return $value instanceof Document && $value->has('utc') && $value->get('utc') instanceof UTCDateTime && $value->has('tz') && is_string($value->get('tz')); } public function canEncode(mixed $value): bool { return $value instanceof DateTimeInterface; } public function decode(mixed $value): DateTimeImmutable { if (! $this->canDecode($value)) { throw UnsupportedValueException::invalidDecodableValue($value); } $timeZone = new DateTimeZone($value->get('tz')); $dateTime = $value->get('utc') ->toDateTime() ->setTimeZone($timeZone); return DateTimeImmutable::createFromMutable($dateTime); } public function encode(mixed $value): Document { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); } return Document::fromPHP([ 'utc' => new UTCDateTime($value), 'tz' => $value->getTimezone()->getName(), ]); } }
Note
When writing a codec, you should be as lenient as possible when it comes to handling data. In this case, the codec
handles any DateTimeInterface
when encoding to BSON, as a UTCDateTime
instance can be created from any such
object. When decoding data from BSON, it will always decode to a DateTimeImmutable
instance.
This codec can now be leveraged by other codecs that handle date fields.
First, we add a createdAt
field to the Person
class:
use MongoDB\BSON\ObjectId; final class Person { public function __construct( public string $name, public readonly DateTimeImmutable $createdAt = new DateTimeImmutable(), public readonly ObjectId $id = new ObjectId(), ) { } }
Last but not least, we modify the codec to handle the new field:
use MongoDB\BSON\Document; use MongoDB\Codec\DecodeIfSupported; use MongoDB\Codec\DocumentCodec; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; /** @template-implements DocumentCodec<Person> */ final class PersonCodec implements DocumentCodec { use DecodeIfSupported; use EncodeIfSupported; public function __construct( private readonly DateTimeCodec $dateTimeCodec = new DateTimeCodec(), ) { } public function canDecode(mixed $value): bool { return $value instanceof Document && $value->has('name'); } public function canEncode(mixed $value): bool { return $value instanceof Person; } public function decode(mixed $value): Person { if (! $this->canDecode($value)) { throw UnsupportedValueException::invalidDecodableValue($value); } return new Person( $value->get('name'), $this->dateTimeCodec->decode($value->get('createdAt')), $value->get('_id'), ); } public function encode(mixed $value): Document { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); } return Document::fromPHP([ '_id' => $value->id, 'name' => $value->name, 'createdAt' => $this->dateTimeCodec->encode($value->createdAt), ]); } }
Handling Embedded Documents
A previous example showed how to handle a single document. However, sometimes you want to handle fields that contain
embedded documents. We will demonstrate this using an Address
document, which we will embed within a Person
document. To ensure consistency, we're going to make this a read-only class:
final readonly class Address { public function __construct( public string $street, public string $postCode, public string $city, public string $country, ) { } }
We can now create a document codec for this class:
use MongoDB\BSON\Document; use MongoDB\Codec\DecodeIfSupported; use MongoDB\Codec\DocumentCodec; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; /** @template-implements DocumentCodec<Address> */ final class AddressCodec implements DocumentCodec { use DecodeIfSupported; use EncodeIfSupported; public function canDecode(mixed $value): bool { return $value instanceof Document && $value->has('street') && $value->has('postCode') && $value->has('city') && $value->has('country'); } public function canEncode(mixed $value): bool { return $value instanceof Address; } public function decode(mixed $value): Address { if (! $this->canDecode($value)) { throw UnsupportedValueException::invalidDecodableValue($value); } return new Address( $value->get('street'), $value->get('postCode'), $value->get('city'), $value->get('country'), ); } public function encode(mixed $value): Document { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); } return Document::fromPHP([ 'street' => $value->street, 'postCode' => $value->postCode, 'city' => $value->city, 'country' => $value->country, ]); } }
The Person
class gets a new address
field, but we'll leave this optional:
use MongoDB\BSON\ObjectId; final class Person { public ?Address $address = null; public function __construct( public string $name, public readonly ObjectId $id = new ObjectId(), ) { } }
The PersonCodec
can now handle the optional address
field when transforming data:
use MongoDB\BSON\Document; use MongoDB\Codec\DecodeIfSupported; use MongoDB\Codec\DocumentCodec; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; /** @template-implements DocumentCodec<Person> */ final class PersonCodec implements DocumentCodec { use DecodeIfSupported; use EncodeIfSupported; public function __construct( private readonly AddressCodec $addressCodec = new AddressCodec(), ) { } public function canDecode(mixed $value): bool { return $value instanceof Document && $value->has('name'); } public function canEncode(mixed $value): bool { return $value instanceof Person; } public function decode(mixed $value): Person { if (! $this->canDecode($value)) { throw UnsupportedValueException::invalidDecodableValue($value); } $person = new Person( $value->get('name'), $value->get('_id'), ); // Address is optional, so only decode if it exists if ($value->has('address')) { $person->address = $this->addressCodec->decode($value->get('address')); } return $person; } public function encode(mixed $value): Document { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); } $data = [ '_id' => $value->id, 'name' => $value->name, ]; // Don't add a null value to the document if address is not set if ($value->address) { $data['address'] = $this->addressCodec->encode($value->address); } return Document::fromPHP($data); } }