spomky-labs/cbor-php
RFC 8949 CBOR encoder/decoder for PHP 8+. Supports all major types, tags (extensible), streaming decode, indefinite-length items, and normalization to native PHP values. Includes common tags and tools for custom tags.
This guide explains how to create custom tag implementations for CBOR data that extends beyond the standard tags defined in RFC 8949.
CBOR tags (Major Type 6) are semantic annotations that give additional meaning to CBOR data items. A tag consists of:
Tag(42, "Hello") → Tag number 42 applied to text string "Hello"
Create custom tags when you need to:
All tags in this library extend the abstract CBOR\Tag class. Here's the basic structure:
namespace CBOR\Tag;
use CBOR\CBORObject;
use CBOR\Tag;
class MyCustomTag extends Tag
{
// Required: Return the IANA tag number
public static function getTagId(): int
{
return 1234; // Your tag number
}
// Required: Create tag from decoded data
public static function createFromLoadedData(
int $additionalInformation,
?string $data,
CBORObject $object
): Tag {
return new self($additionalInformation, $data, $object);
}
// Optional: Convenience constructor
public static function create(CBORObject $object): self
{
[$ai, $data] = self::determineComponents(self::getTagId());
return new self($ai, $data, $object);
}
}
getTagId(): Returns the IANA-registered tag numbercreateFromLoadedData(): Factory method used by the decodercreate(): Convenience method for creating tagged objectsnormalize(): Convert to native PHP types (implement Normalizable interface)For Private Use: Use tag numbers in the range 256-55799 (unregistered) For Public Use: Register with IANA CBOR Tags Registry
// Private use example
private const TAG_MY_CUSTOM = 1234;
namespace CBOR\Tag;
use CBOR\CBORObject;
use CBOR\Tag;
final class MyCustomTag extends Tag
{
private const TAG_NUMBER = 1234;
public static function getTagId(): int
{
return self::TAG_NUMBER;
}
public static function createFromLoadedData(
int $additionalInformation,
?string $data,
CBORObject $object
): Tag {
return new self($additionalInformation, $data, $object);
}
}
Validate the wrapped object in the constructor:
use InvalidArgumentException;
use CBOR\TextStringObject;
public function __construct(
int $additionalInformation,
?string $data,
CBORObject $object
) {
// Validate that the object is the expected type
if (!$object instanceof TextStringObject) {
throw new InvalidArgumentException(
'MyCustomTag only accepts TextStringObject'
);
}
// Add custom validation
$value = $object->getValue();
if (strlen($value) < 5) {
throw new InvalidArgumentException(
'Value must be at least 5 characters'
);
}
parent::__construct($additionalInformation, $data, $object);
}
Implement the Normalizable interface to convert to PHP types:
use CBOR\Normalizable;
final class MyCustomTag extends Tag implements Normalizable
{
public function normalize(): mixed
{
/** [@var](https://github.com/var) TextStringObject $object */
$object = $this->object;
// Custom transformation logic
return strtoupper($object->getValue());
}
}
public static function create(CBORObject $object): self
{
[$ai, $data] = self::determineComponents(self::TAG_NUMBER);
return new self($ai, $data, $object);
}
public static function createFromString(string $value): self
{
return self::create(TextStringObject::create($value));
}
A tag that marks text strings as email addresses.
namespace CBOR\Tag;
use CBOR\CBORObject;
use CBOR\IndefiniteLengthTextStringObject;
use CBOR\Normalizable;
use CBOR\Tag;
use CBOR\TextStringObject;
use InvalidArgumentException;
/**
* Tag 260: Email Address
* Marks a text string as an RFC 5322 email address
*/
final class EmailTag extends Tag implements Normalizable
{
private const TAG_EMAIL = 260;
public function __construct(
int $additionalInformation,
?string $data,
CBORObject $object
) {
if (!$object instanceof TextStringObject
&& !$object instanceof IndefiniteLengthTextStringObject) {
throw new InvalidArgumentException(
'EmailTag only accepts text strings'
);
}
// Validate email format
$email = $object->getValue();
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException(
'Invalid email address format'
);
}
parent::__construct($additionalInformation, $data, $object);
}
public static function getTagId(): int
{
return self::TAG_EMAIL;
}
public static function createFromLoadedData(
int $additionalInformation,
?string $data,
CBORObject $object
): Tag {
return new self($additionalInformation, $data, $object);
}
public static function create(CBORObject $object): self
{
[$ai, $data] = self::determineComponents(self::TAG_EMAIL);
return new self($ai, $data, $object);
}
public static function createFromEmail(string $email): self
{
return self::create(TextStringObject::create($email));
}
public function normalize(): string
{
/** [@var](https://github.com/var) TextStringObject|IndefiniteLengthTextStringObject $object */
$object = $this->object;
return $object->normalize();
}
public function getEmail(): string
{
return $this->normalize();
}
}
Usage:
use CBOR\Tag\EmailTag;
// Create
$email = EmailTag::createFromEmail('user@example.com');
$encoded = (string) $email;
// Decode
$decoder = Decoder::create();
$decoded = $decoder->decode(StringStream::create($encoded));
if ($decoded instanceof EmailTag) {
echo $decoded->getEmail(); // user@example.com
}
A tag for WGS84 coordinates (latitude, longitude, altitude).
namespace CBOR\Tag;
use CBOR\CBORObject;
use CBOR\ListObject;
use CBOR\Normalizable;
use CBOR\Tag;
use CBOR\OtherObject\DoublePrecisionFloatObject;
use InvalidArgumentException;
/**
* Tag 103: Geographic Coordinates (WGS-84)
* Array of [latitude, longitude, altitude (optional)]
*/
final class GeoCoordinatesTag extends Tag implements Normalizable
{
private const TAG_GEO_COORDINATES = 103;
public function __construct(
int $additionalInformation,
?string $data,
CBORObject $object
) {
if (!$object instanceof ListObject) {
throw new InvalidArgumentException(
'GeoCoordinatesTag requires a ListObject'
);
}
$count = count($object);
if ($count < 2 || $count > 3) {
throw new InvalidArgumentException(
'GeoCoordinates must have 2 or 3 elements [lat, lon, alt?]'
);
}
// Validate latitude
$lat = $object->get(0);
if (!$this->isNumeric($lat)) {
throw new InvalidArgumentException('Latitude must be numeric');
}
// Validate longitude
$lon = $object->get(1);
if (!$this->isNumeric($lon)) {
throw new InvalidArgumentException('Longitude must be numeric');
}
// Validate altitude if present
if ($count === 3) {
$alt = $object->get(2);
if (!$this->isNumeric($alt)) {
throw new InvalidArgumentException('Altitude must be numeric');
}
}
parent::__construct($additionalInformation, $data, $object);
}
private function isNumeric(CBORObject $object): bool
{
return $object instanceof UnsignedIntegerObject
|| $object instanceof NegativeIntegerObject
|| $object instanceof DoublePrecisionFloatObject
|| $object instanceof SinglePrecisionFloatObject;
}
public static function getTagId(): int
{
return self::TAG_GEO_COORDINATES;
}
public static function createFromLoadedData(
int $additionalInformation,
?string $data,
CBORObject $object
): Tag {
return new self($additionalInformation, $data, $object);
}
public static function create(CBORObject $object): self
{
[$ai, $data] = self::determineComponents(self::TAG_GEO_COORDINATES);
return new self($ai, $data, $object);
}
public static function createFromCoordinates(
float $latitude,
float $longitude,
?float $altitude = null
): self {
$list = ListObject::create([
DoublePrecisionFloatObject::create($latitude),
DoublePrecisionFloatObject::create($longitude),
]);
if ($altitude !== null) {
$list->add(DoublePrecisionFloatObject::create($altitude));
}
return self::create($list);
}
/**
* [@return](https://github.com/return) array{latitude: float, longitude: float, altitude: float|null}
*/
public function normalize(): array
{
/** [@var](https://github.com/var) ListObject $list */
$list = $this->object;
$result = [
'latitude' => (float) $list->get(0)->normalize(),
'longitude' => (float) $list->get(1)->normalize(),
'altitude' => null,
];
if (count($list) === 3) {
$result['altitude'] = (float) $list->get(2)->normalize();
}
return $result;
}
public function getLatitude(): float
{
/** [@var](https://github.com/var) ListObject $list */
$list = $this->object;
return (float) $list->get(0)->normalize();
}
public function getLongitude(): float
{
/** [@var](https://github.com/var) ListObject $list */
$list = $this->object;
return (float) $list->get(1)->normalize();
}
public function getAltitude(): ?float
{
/** [@var](https://github.com/var) ListObject $list */
$list = $this->object;
if (count($list) === 3) {
return (float) $list->get(2)->normalize();
}
return null;
}
}
Usage:
use CBOR\Tag\GeoCoordinatesTag;
// Create coordinates for Paris, France
$coords = GeoCoordinatesTag::createFromCoordinates(
48.8566, // latitude
2.3522, // longitude
35.0 // altitude (meters)
);
$normalized = $coords->normalize();
/*
[
'latitude' => 48.8566,
'longitude' => 2.3522,
'altitude' => 35.0
]
*/
echo $coords->getLatitude(); // 48.8566
echo $coords->getLongitude(); // 2.3522
echo $coords->getAltitude(); // 35.0
A tag with multiple validation rules and transformation logic.
namespace CBOR\Tag;
use CBOR\CBORObject;
use CBOR\MapObject;
use CBOR\Normalizable;
use CBOR\Tag;
use CBOR\TextStringObject;
use CBOR\UnsignedIntegerObject;
use InvalidArgumentException;
/**
* Tag 1000: User Profile
* Structured user profile with validation
*/
final class UserProfileTag extends Tag implements Normalizable
{
private const TAG_USER_PROFILE = 1000;
private const REQUIRED_KEYS = ['username', 'email', 'age'];
public function __construct(
int $additionalInformation,
?string $data,
CBORObject $object
) {
if (!$object instanceof MapObject) {
throw new InvalidArgumentException(
'UserProfileTag requires a MapObject'
);
}
// Validate required keys
foreach (self::REQUIRED_KEYS as $key) {
if (!$object->has($key)) {
throw new InvalidArgumentException(
"Missing required key: {$key}"
);
}
}
// Validate username
$username = $object->get('username');
if (!$username instanceof TextStringObject) {
throw new InvalidArgumentException('Username must be a text string');
}
if (strlen($username->getValue()) < 3) {
throw new InvalidArgumentException('Username must be at least 3 characters');
}
// Validate email
$email = $object->get('email');
if (!$email instanceof TextStringObject) {
throw new InvalidArgumentException('Email must be a text string');
}
if (!filter_var($email->getValue(), FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email format');
}
// Validate age
$age = $object->get('age');
if (!$age instanceof UnsignedIntegerObject) {
throw new InvalidArgumentException('Age must be an unsigned integer');
}
$ageValue = (int) $age->getValue();
if ($ageValue < 0 || $ageValue > 150) {
throw new InvalidArgumentException('Age must be between 0 and 150');
}
parent::__construct($additionalInformation, $data, $object);
}
public static function getTagId(): int
{
return self::TAG_USER_PROFILE;
}
public static function createFromLoadedData(
int $additionalInformation,
?string $data,
CBORObject $object
): Tag {
return new self($additionalInformation, $data, $object);
}
public static function create(CBORObject $object): self
{
[$ai, $data] = self::determineComponents(self::TAG_USER_PROFILE);
return new self($ai, $data, $object);
}
public static function createFromArray(array $profile): self
{
$map = MapObject::create()
->add(
TextStringObject::create('username'),
TextStringObject::create($profile['username'])
)
->add(
TextStringObject::create('email'),
TextStringObject::create($profile['email'])
)
->add(
TextStringObject::create('age'),
UnsignedIntegerObject::create($profile['age'])
);
return self::create($map);
}
public function normalize(): array
{
/** [@var](https://github.com/var) MapObject $map */
$map = $this->object;
return [
'username' => $map->get('username')->normalize(),
'email' => $map->get('email')->normalize(),
'age' => (int) $map->get('age')->normalize(),
];
}
}
Usage:
use CBOR\Tag\UserProfileTag;
// Create from array
$profile = UserProfileTag::createFromArray([
'username' => 'john_doe',
'email' => 'john@example.com',
'age' => 30,
]);
// Validation happens automatically
try {
$invalid = UserProfileTag::createFromArray([
'username' => 'jo', // Too short!
'email' => 'invalid-email',
'age' => 30,
]);
} catch (InvalidArgumentException $e) {
echo $e->getMessage(); // "Username must be at least 3 characters"
}
Tags can wrap other tagged values:
// Timestamp wrapped in a custom "audit log" tag
$timestamp = TimestampTag::create(UnsignedIntegerObject::create(time()));
$auditLog = AuditLogTag::create($timestamp);
instanceof checks sparinglypublic function __construct(
int $additionalInformation,
?string $data,
CBORObject $object
) {
try {
// Validation logic
$this->validate($object);
} catch (\Exception $e) {
throw new InvalidArgumentException(
sprintf('Invalid %s: %s', static::class, $e->getMessage()),
0,
$e
);
}
parent::__construct($additionalInformation, $data, $object);
}
COSE uses several CBOR tags:
Example implementation outline:
namespace CBOR\Tag\COSE;
use CBOR\CBORObject;
use CBOR\Tag;
use CBOR\ListObject;
/**
* Tag 98: COSE_Sign1 - Single Signer
* [@see](https://github.com/see) https://datatracker.ietf.org/doc/html/rfc8152#section-4.2
*/
final class COSESign1Tag extends Tag
{
private const TAG_COSE_SIGN1 = 98;
public function __construct(
int $additionalInformation,
?string $data,
CBORObject $object
) {
if (!$object instanceof ListObject || count($object) !== 4) {
throw new InvalidArgumentException(
'COSE_Sign1 must be an array of 4 elements'
);
}
// Validate structure: [protected, unprotected, payload, signature]
// ... validation logic
parent::__construct($additionalInformation, $data, $object);
}
public static function getTagId(): int
{
return self::TAG_COSE_SIGN1;
}
// ... implementation
}
CWT uses Tag 61 for the entire token:
namespace CBOR\Tag;
use CBOR\CBORObject;
use CBOR\Tag;
use CBOR\Tag\COSE\COSESign1Tag;
/**
* Tag 61: CBOR Web Token (CWT)
* [@see](https://github.com/see) https://datatracker.ietf.org/doc/html/rfc8392
*/
final class CWTTag extends Tag
{
private const TAG_CWT = 61;
public function __construct(
int $additionalInformation,
?string $data,
CBORObject $object
) {
// CWT is typically a COSE_Sign1 or COSE_Mac0
if (!$object instanceof COSESign1Tag
&& !$object instanceof COSEMac0Tag) {
throw new InvalidArgumentException(
'CWT must wrap a COSE structure'
);
}
parent::__construct($additionalInformation, $data, $object);
}
// ... implementation
}
Custom tags for CoAP-specific data types.
SenML uses CBOR extensively with custom semantics.
use CBOR\Decoder;
use CBOR\Tag\TagManager;
$tagManager = TagManager::create()
->add(EmailTag::class)
->add(GeoCoordinatesTag::class)
->add(UserProfileTag::class);
$decoder = Decoder::create($tagManager);
// Encoding
$email = EmailTag::createFromEmail('user@example.com');
$encoded = (string) $email;
// Decoding
$decoded = $decoder->decode(StringStream::create($encoded));
if ($decoded instanceof EmailTag) {
echo $decoded->getEmail();
}
use PHPUnit\Framework\TestCase;
class EmailTagTest extends TestCase
{
public function testCreateFromValidEmail(): void
{
$tag = EmailTag::createFromEmail('test@example.com');
$this->assertInstanceOf(EmailTag::class, $tag);
$this->assertSame('test@example.com', $tag->getEmail());
}
public function testRejectsInvalidEmail(): void
{
$this->expectException(InvalidArgumentException::class);
EmailTag::createFromEmail('not-an-email');
}
public function testEncodingAndDecoding(): void
{
$original = EmailTag::createFromEmail('test@example.com');
$encoded = (string) $original;
$tagManager = TagManager::create()->add(EmailTag::class);
$decoder = Decoder::create($tagManager);
$decoded = $decoder->decode(StringStream::create($encoded));
$this->assertInstanceOf(EmailTag::class, $decoded);
$this->assertSame('test@example.com', $decoded->getEmail());
}
}
How can I help you explore Laravel packages today?