This is an automated email from the ASF dual-hosted git repository.

mgrigorov pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/avro.git


The following commit(s) were added to refs/heads/main by this push:
     new 1e9a16ab77 AVRO-4190: [php] Add logical types (#3526)
1e9a16ab77 is described below

commit 1e9a16ab77d1019a5a62a1f671982894b479c044
Author: Mattia Basone <[email protected]>
AuthorDate: Fri Oct 24 14:58:34 2025 +0200

    AVRO-4190: [php] Add logical types (#3526)
    
    * wip
    
    * update logical types
    
    * wip - added logical types
    
    * update PHP workflow
    
    * add composer.json to workflow path
    
    * bump composer version
    
    * add license to AvroLogicalType.php
    
    * add test on logical types
    
    * update test and fix default decimal value
    
    * improve exception messages
    
    * fix lint
    
    * improve types
    
    * add test for decimal schema validation
    
    * add test for decimal schema validation
    
    * remove is_numeric
    
    * add some test for decimal logical type (bytes)
    
    * Add AvroDuration type and extend fixed schema
    
    * additional tests for decimal
---
 .github/workflows/test-lang-php.yml         |  13 +-
 composer.json                               |   2 +-
 lang/php/lib/DataFile/AvroDataIOWriter.php  |   2 +-
 lang/php/lib/Datum/AvroIOBinaryEncoder.php  |  72 ++++-
 lang/php/lib/Datum/AvroIODatumReader.php    |  94 ++++--
 lang/php/lib/Datum/AvroIODatumWriter.php    | 141 +++++---
 lang/php/lib/Datum/Type/AvroDuration.php    | 114 +++++++
 lang/php/lib/Schema/AvroFixedSchema.php     |  53 +++
 lang/php/lib/Schema/AvroLogicalType.php     | 128 ++++++++
 lang/php/lib/Schema/AvroPrimitiveSchema.php |  74 +++++
 lang/php/lib/Schema/AvroRecordSchema.php    |   6 +-
 lang/php/lib/Schema/AvroSchema.php          | 161 +++++++++-
 lang/php/phpunit.xml                        |   2 +-
 lang/php/test/DatumIOTest.php               | 483 +++++++++++++++++++++-------
 lang/php/test/IODatumReaderTest.php         | 220 +++++++++++--
 lang/php/test/SchemaTest.php                | 255 +++++++++++----
 16 files changed, 1518 insertions(+), 302 deletions(-)

diff --git a/.github/workflows/test-lang-php.yml 
b/.github/workflows/test-lang-php.yml
index 94d19d68f1..883ef5ce11 100644
--- a/.github/workflows/test-lang-php.yml
+++ b/.github/workflows/test-lang-php.yml
@@ -22,6 +22,7 @@ on:
     branches: [ main ]
     paths:
     - .github/workflows/test-lang-php.yml
+    - composer.json
     - lang/php/**
 
 defaults:
@@ -44,6 +45,10 @@ jobs:
           - '7.3'
           - '7.4'
           - '8.0'
+          - '8.1'
+          - '8.2'
+          - '8.3'
+          - '8.4'
 
     steps:
       - uses: actions/checkout@v5
@@ -52,7 +57,7 @@ jobs:
         uses: shivammathur/setup-php@v2
         with:
           php-version: ${{ matrix.php }}
-          tools: composer:2.2.5
+          tools: composer:2.8.12
 
       - name: Get Composer Cache Directory
         id: composer-cache
@@ -82,6 +87,10 @@ jobs:
           - '7.3'
           - '7.4'
           - '8.0'
+          - '8.1'
+          - '8.2'
+          - '8.3'
+          - '8.4'
 
     steps:
       - uses: actions/checkout@v5
@@ -90,7 +99,7 @@ jobs:
         uses: shivammathur/setup-php@v2
         with:
           php-version: ${{ matrix.php }}
-          tools: composer:2.2.5
+          tools: composer:2.8.12
 
       - name: Cache Local Maven Repository
         uses: actions/cache@v4
diff --git a/composer.json b/composer.json
index f48c7d594e..3b5e06ef21 100644
--- a/composer.json
+++ b/composer.json
@@ -23,7 +23,7 @@
     "issues": "https://issues.apache.org/jira/browse/AVRO";
   },
   "require": {
-    "php": "^7.1 || ^8.0"
+    "php": "^7.3 || ^8.0"
   },
   "deps": [
     "vendor/phpunit/phpunit",
diff --git a/lang/php/lib/DataFile/AvroDataIOWriter.php 
b/lang/php/lib/DataFile/AvroDataIOWriter.php
index 730a2ef2dd..b28143723d 100644
--- a/lang/php/lib/DataFile/AvroDataIOWriter.php
+++ b/lang/php/lib/DataFile/AvroDataIOWriter.php
@@ -136,7 +136,7 @@ class AvroDataIOWriter
     /**
      * Writes the header of the AvroIO object container
      */
-    private function writeHeader()
+    private function writeHeader(): void
     {
         $this->write(AvroDataIO::magic());
         $this->datum_writer->writeData(
diff --git a/lang/php/lib/Datum/AvroIOBinaryEncoder.php 
b/lang/php/lib/Datum/AvroIOBinaryEncoder.php
index d4c47248c1..5f289aafa9 100644
--- a/lang/php/lib/Datum/AvroIOBinaryEncoder.php
+++ b/lang/php/lib/Datum/AvroIOBinaryEncoder.php
@@ -21,6 +21,7 @@
 namespace Apache\Avro\Datum;
 
 use Apache\Avro\Avro;
+use Apache\Avro\AvroException;
 use Apache\Avro\AvroGMP;
 use Apache\Avro\AvroIO;
 
@@ -41,7 +42,7 @@ class AvroIOBinaryEncoder
      * @param AvroIO $io object to which data is to be written.
      *
      */
-    public function __construct($io)
+    public function __construct(AvroIO $io)
     {
         Avro::checkPlatform();
         $this->io = $io;
@@ -50,9 +51,9 @@ class AvroIOBinaryEncoder
     /**
      * @param null $datum actual value is ignored
      */
-    public function writeNull($datum)
+    public function writeNull($datum): void
     {
-        return null;
+        return;
     }
 
     /**
@@ -67,7 +68,7 @@ class AvroIOBinaryEncoder
     /**
      * @param string $datum
      */
-    public function write($datum)
+    public function write($datum): void
     {
         $this->io->write($datum);
     }
@@ -75,7 +76,7 @@ class AvroIOBinaryEncoder
     /**
      * @param int $datum
      */
-    public function writeInt($datum)
+    public function writeInt($datum): void
     {
         $this->writeLong($datum);
     }
@@ -83,7 +84,7 @@ class AvroIOBinaryEncoder
     /**
      * @param int $n
      */
-    public function writeLong($n)
+    public function writeLong($n): void
     {
         if (Avro::usesGmp()) {
             $this->write(AvroGMP::encodeLong($n));
@@ -125,7 +126,7 @@ class AvroIOBinaryEncoder
      * @param float $datum
      * @uses self::floatToIntBits()
      */
-    public function writeFloat($datum)
+    public function writeFloat($datum): void
     {
         $this->write(self::floatToIntBits($datum));
     }
@@ -142,7 +143,7 @@ class AvroIOBinaryEncoder
      * @returns string bytes
      * @see Avro::checkPlatform()
      */
-    public static function floatToIntBits($float)
+    public static function floatToIntBits($float): string
     {
         return pack('g', (float) $float);
     }
@@ -151,7 +152,7 @@ class AvroIOBinaryEncoder
      * @param float $datum
      * @uses self::doubleToLongBits()
      */
-    public function writeDouble($datum)
+    public function writeDouble($datum): void
     {
         $this->write(self::doubleToLongBits($datum));
     }
@@ -165,7 +166,7 @@ class AvroIOBinaryEncoder
      * @param double $double
      * @returns string bytes
      */
-    public static function doubleToLongBits($double)
+    public static function doubleToLongBits($double): string
     {
         return pack('e', (double) $double);
     }
@@ -174,7 +175,7 @@ class AvroIOBinaryEncoder
      * @param string $str
      * @uses self::writeBytes()
      */
-    public function writeString($str)
+    public function writeString($str): void
     {
         $this->writeBytes($str);
     }
@@ -182,9 +183,56 @@ class AvroIOBinaryEncoder
     /**
      * @param string $bytes
      */
-    public function writeBytes($bytes)
+    public function writeBytes($bytes): void
     {
         $this->writeLong(strlen($bytes));
         $this->write($bytes);
     }
+
+    public function writeDecimal($decimal, int $scale, int $precision): void
+    {
+        if (!is_numeric($decimal)) {
+            throw new AvroException("Decimal value '{$decimal}' must be 
numeric");
+        }
+
+        $value = $decimal * (10 ** $scale);
+        if (!is_int($value)) {
+            $value = (int) round($value);
+        }
+
+        $maxValue = 10 ** $precision;
+        if (abs($value) >= $maxValue) {
+            throw new AvroException(
+                "Decimal value '{$decimal}' is out of range for 
precision={$precision}, scale={$scale}"
+            );
+        }
+
+        $packed = pack('J', $value);
+
+        $significantBit = self::getMostSignificantBitAt($packed, 0);
+        $trimByte = $significantBit ? 0xff : 0x00;
+
+        $offset = 0;
+        $packedLength = strlen($packed);
+        while ($offset < $packedLength - 1) {
+            if (ord($packed[$offset]) !== $trimByte) {
+                break;
+            }
+
+            if (self::getMostSignificantBitAt($packed, $offset + 1) !== 
$significantBit) {
+                break;
+            }
+
+            $offset++;
+        }
+
+        $value = substr($packed, $offset);
+
+        $this->writeBytes($value);
+    }
+
+    private static function getMostSignificantBitAt($bytes, $offset): int
+    {
+        return ord($bytes[$offset]) & 0x80;
+    }
 }
diff --git a/lang/php/lib/Datum/AvroIODatumReader.php 
b/lang/php/lib/Datum/AvroIODatumReader.php
index c9a5ae3953..24278a3dfb 100644
--- a/lang/php/lib/Datum/AvroIODatumReader.php
+++ b/lang/php/lib/Datum/AvroIODatumReader.php
@@ -21,6 +21,8 @@
 namespace Apache\Avro\Datum;
 
 use Apache\Avro\AvroException;
+use Apache\Avro\Datum\Type\AvroDuration;
+use Apache\Avro\Schema\AvroLogicalType;
 use Apache\Avro\Schema\AvroName;
 use Apache\Avro\Schema\AvroSchema;
 
@@ -43,20 +45,13 @@ class AvroIODatumReader
      */
     private $readers_schema;
 
-    /**
-     * @param AvroSchema $writers_schema
-     * @param AvroSchema $readers_schema
-     */
-    public function __construct($writers_schema = null, $readers_schema = null)
+    public function __construct(?AvroSchema $writers_schema = null, 
?AvroSchema $readers_schema = null)
     {
         $this->writers_schema = $writers_schema;
         $this->readers_schema = $readers_schema;
     }
 
-    /**
-     * @param AvroSchema $readers_schema
-     */
-    public function setWritersSchema($readers_schema)
+    public function setWritersSchema(AvroSchema $readers_schema): void
     {
         $this->writers_schema = $readers_schema;
     }
@@ -80,7 +75,7 @@ class AvroIODatumReader
     /**
      * @returns mixed
      */
-    public function readData($writers_schema, $readers_schema, $decoder)
+    public function readData(AvroSchema $writers_schema, AvroSchema 
$readers_schema, AvroIOBinaryDecoder $decoder)
     {
         // Schema resolution: reader's schema is a union, writer's schema is 
not
         if (
@@ -111,7 +106,8 @@ class AvroIODatumReader
             case AvroSchema::STRING_TYPE:
                 return $decoder->readString();
             case AvroSchema::BYTES_TYPE:
-                return $decoder->readBytes();
+                $bytes = $decoder->readBytes();
+                return $this->readBytes($writers_schema, $readers_schema, 
$bytes);
             case AvroSchema::ARRAY_SCHEMA:
                 return $this->readArray($writers_schema, $readers_schema, 
$decoder);
             case AvroSchema::MAP_SCHEMA:
@@ -136,12 +132,10 @@ class AvroIODatumReader
 
     /**
      *
-     * @param AvroSchema $writers_schema
-     * @param AvroSchema $readers_schema
      * @returns boolean true if the schemas are consistent with
      *                  each other and false otherwise.
      */
-    public static function schemasMatch($writers_schema, $readers_schema)
+    public static function schemasMatch(AvroSchema $writers_schema, AvroSchema 
$readers_schema)
     {
         $writers_schema_type = $writers_schema->type;
         $readers_schema_type = $readers_schema->type;
@@ -167,12 +161,6 @@ class AvroIODatumReader
                     $readers_schema->items(),
                     [AvroSchema::TYPE_ATTR]
                 );
-            case AvroSchema::ENUM_SCHEMA:
-                return self::attributesMatch(
-                    $writers_schema,
-                    $readers_schema,
-                    [AvroSchema::FULLNAME_ATTR]
-                );
             case AvroSchema::FIXED_SCHEMA:
                 return self::attributesMatch(
                     $writers_schema,
@@ -182,6 +170,7 @@ class AvroIODatumReader
                         AvroSchema::SIZE_ATTR
                     ]
                 );
+            case AvroSchema::ENUM_SCHEMA:
             case AvroSchema::RECORD_SCHEMA:
             case AvroSchema::ERROR_SCHEMA:
                 return self::attributesMatch(
@@ -255,10 +244,28 @@ class AvroIODatumReader
         return true;
     }
 
+    public function readBytes(AvroSchema $writers_schema, AvroSchema 
$readers_schema, string $bytes): string
+    {
+        $logicalTypeWriters = $writers_schema->logicalType();
+        if (
+            $logicalTypeWriters instanceof AvroLogicalType
+            && $logicalTypeWriters->name() === AvroSchema::DECIMAL_LOGICAL_TYPE
+        ) {
+            if ($logicalTypeWriters !== $readers_schema->logicalType()) {
+                throw new AvroIOSchemaMatchException($writers_schema, 
$readers_schema);
+            }
+
+            $scale = $logicalTypeWriters->attributes()['scale'] ?? 0;
+            $bytes = $this->readDecimal($bytes, $scale);
+        }
+
+        return $bytes;
+    }
+
     /**
      * @return array
      */
-    public function readArray($writers_schema, $readers_schema, $decoder)
+    public function readArray(AvroSchema $writers_schema, AvroSchema 
$readers_schema, AvroIOBinaryDecoder $decoder)
     {
         $items = array();
         $block_count = $decoder->readLong();
@@ -282,7 +289,7 @@ class AvroIODatumReader
     /**
      * @returns array
      */
-    public function readMap($writers_schema, $readers_schema, $decoder)
+    public function readMap(AvroSchema $writers_schema, AvroSchema 
$readers_schema, AvroIOBinaryDecoder $decoder)
     {
         $items = array();
         $pair_count = $decoder->readLong();
@@ -309,7 +316,7 @@ class AvroIODatumReader
     /**
      * @returns mixed
      */
-    public function readUnion($writers_schema, $readers_schema, $decoder)
+    public function readUnion(AvroSchema $writers_schema, AvroSchema 
$readers_schema, AvroIOBinaryDecoder $decoder)
     {
         $schema_index = $decoder->readLong();
         $selected_writers_schema = 
$writers_schema->schemaByIndex($schema_index);
@@ -319,7 +326,7 @@ class AvroIODatumReader
     /**
      * @returns string
      */
-    public function readEnum($writers_schema, $readers_schema, $decoder)
+    public function readEnum(AvroSchema $writers_schema, AvroSchema 
$readers_schema, AvroIOBinaryDecoder $decoder)
     {
         $symbol_index = $decoder->readInt();
         $symbol = $writers_schema->symbolByIndex($symbol_index);
@@ -330,17 +337,37 @@ class AvroIODatumReader
     }
 
     /**
-     * @returns string
+     * @returns string|AvroDuration
      */
-    public function readFixed($writers_schema, $readers_schema, $decoder)
+    public function readFixed(AvroSchema $writers_schema, AvroSchema 
$readers_schema, AvroIOBinaryDecoder $decoder)
     {
+        $logicalTypeWriters = $writers_schema->logicalType();
+        if ($logicalTypeWriters instanceof AvroLogicalType) {
+            if ($logicalTypeWriters !== $readers_schema->logicalType()) {
+                throw new AvroIOSchemaMatchException($writers_schema, 
$readers_schema);
+            }
+
+            switch ($logicalTypeWriters->name()) {
+                case AvroSchema::DECIMAL_LOGICAL_TYPE:
+                    $scale = $logicalTypeWriters->attributes()['scale'] ?? 0;
+                    return $this->readDecimal($decoder->readBytes(), $scale);
+                case AvroSchema::DURATION_LOGICAL_TYPE:
+                    $encodedDuration = $decoder->read($writers_schema->size());
+                    if (strlen($encodedDuration) !== 12) {
+                        throw new AvroException('Invalid duration fixed size: 
' . strlen($encodedDuration));
+                    }
+
+                    return AvroDuration::fromBytes($encodedDuration);
+            }
+        }
+
         return $decoder->read($writers_schema->size());
     }
 
     /**
      * @returns array
      */
-    public function readRecord($writers_schema, $readers_schema, $decoder)
+    public function readRecord(AvroSchema $writers_schema, AvroSchema 
$readers_schema, AvroIOBinaryDecoder $decoder)
     {
         $readers_fields = $readers_schema->fieldsHash();
         $record = [];
@@ -419,13 +446,12 @@ class AvroIODatumReader
     }
 
     /**
-     * @param AvroSchema $field_schema
      * @param null|boolean|int|float|string|array $default_value
      * @returns null|boolean|int|float|string|array
      *
      * @throws AvroException if $field_schema type is unknown.
      */
-    public function readDefaultValue($field_schema, $default_value)
+    public function readDefaultValue(AvroSchema $field_schema, $default_value)
     {
         switch ($field_schema->type()) {
             case AvroSchema::NULL_TYPE:
@@ -440,7 +466,7 @@ class AvroIODatumReader
                 return (float) $default_value;
             case AvroSchema::STRING_TYPE:
             case AvroSchema::BYTES_TYPE:
-                return $default_value;
+                return $this->readBytes($field_schema, $field_schema, 
$default_value);
             case AvroSchema::ARRAY_SCHEMA:
                 $array = array();
                 foreach ($default_value as $json_val) {
@@ -483,4 +509,12 @@ class AvroIODatumReader
                 throw new AvroException(sprintf('Unknown type: %s', 
$field_schema->type()));
         }
     }
+
+    private function readDecimal(string $bytes, int $scale): string
+    {
+        $mostSignificantBit = ord($bytes[0]) & 0x80;
+        $padded = str_pad($bytes, 8, $mostSignificantBit ? "\xff" : "\x00", 
STR_PAD_LEFT);
+        $int = unpack('J', $padded)[1];
+        return (string) ($scale > 0 ? ($int / (10 ** $scale)) : $int);
+    }
 }
diff --git a/lang/php/lib/Datum/AvroIODatumWriter.php 
b/lang/php/lib/Datum/AvroIODatumWriter.php
index ef8031f763..13e2bbe388 100644
--- a/lang/php/lib/Datum/AvroIODatumWriter.php
+++ b/lang/php/lib/Datum/AvroIODatumWriter.php
@@ -21,7 +21,10 @@
 namespace Apache\Avro\Datum;
 
 use Apache\Avro\AvroException;
+use Apache\Avro\Datum\Type\AvroDuration;
+use Apache\Avro\Schema\AvroLogicalType;
 use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroSchemaParseException;
 
 /**
  * Handles schema-specific writing of data to the encoder.
@@ -38,19 +41,16 @@ class AvroIODatumWriter
      */
     public $writersSchema;
 
-    /**
-     * @param AvroSchema $writers_schema
-     */
-    public function __construct($writers_schema = null)
+    public function __construct(?AvroSchema $writers_schema = null)
     {
         $this->writersSchema = $writers_schema;
     }
 
     /**
-     * @param $datum
+     * @param mixed $datum
      * @param AvroIOBinaryEncoder $encoder
      */
-    public function write($datum, $encoder)
+    public function write($datum, AvroIOBinaryEncoder $encoder)
     {
         $this->writeData($this->writersSchema, $datum, $encoder);
     }
@@ -62,57 +62,72 @@ class AvroIODatumWriter
      * @return mixed
      *
      * @throws AvroIOTypeException if $datum is invalid for $writers_schema
+     * @throws AvroException if the type is invalid
      */
-    public function writeData($writers_schema, $datum, $encoder)
+    public function writeData(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder): void
     {
         if (!AvroSchema::isValidDatum($writers_schema, $datum)) {
             throw new AvroIOTypeException($writers_schema, $datum);
         }
 
-        return $this->writeValidatedData($writers_schema, $datum, $encoder);
+        $this->writeValidatedData($writers_schema, $datum, $encoder);
     }
 
     /**
      * @param AvroSchema $writers_schema
      * @param $datum
      * @param AvroIOBinaryEncoder $encoder
-     * @return mixed
+     * @return void
      *
      * @throws AvroIOTypeException if $datum is invalid for $writers_schema
      */
-    private function writeValidatedData($writers_schema, $datum, $encoder)
+    private function writeValidatedData(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder)
     {
         switch ($writers_schema->type()) {
             case AvroSchema::NULL_TYPE:
-                return $encoder->writeNull($datum);
+                $encoder->writeNull($datum);
+                return;
             case AvroSchema::BOOLEAN_TYPE:
-                return $encoder->writeBoolean($datum);
+                $encoder->writeBoolean($datum);
+                return;
             case AvroSchema::INT_TYPE:
-                return $encoder->writeInt($datum);
+                $encoder->writeInt($datum);
+                return;
             case AvroSchema::LONG_TYPE:
-                return $encoder->writeLong($datum);
+                $encoder->writeLong($datum);
+                return;
             case AvroSchema::FLOAT_TYPE:
-                return $encoder->writeFloat($datum);
+                $encoder->writeFloat($datum);
+                return;
             case AvroSchema::DOUBLE_TYPE:
-                return $encoder->writeDouble($datum);
+                $encoder->writeDouble($datum);
+                return;
             case AvroSchema::STRING_TYPE:
-                return $encoder->writeString($datum);
+                $encoder->writeString($datum);
+                return;
             case AvroSchema::BYTES_TYPE:
-                return $encoder->writeBytes($datum);
+                $this->writeBytes($writers_schema, $datum, $encoder);
+                return;
             case AvroSchema::ARRAY_SCHEMA:
-                return $this->writeArray($writers_schema, $datum, $encoder);
+                $this->writeArray($writers_schema, $datum, $encoder);
+                return;
             case AvroSchema::MAP_SCHEMA:
-                return $this->writeMap($writers_schema, $datum, $encoder);
+                $this->writeMap($writers_schema, $datum, $encoder);
+                return;
             case AvroSchema::FIXED_SCHEMA:
-                return $this->writeFixed($writers_schema, $datum, $encoder);
+                $this->writeFixed($writers_schema, $datum, $encoder);
+                return;
             case AvroSchema::ENUM_SCHEMA:
-                return $this->writeEnum($writers_schema, $datum, $encoder);
+                $this->writeEnum($writers_schema, $datum, $encoder);
+                return;
             case AvroSchema::RECORD_SCHEMA:
             case AvroSchema::ERROR_SCHEMA:
             case AvroSchema::REQUEST_SCHEMA:
-                return $this->writeRecord($writers_schema, $datum, $encoder);
+                $this->writeRecord($writers_schema, $datum, $encoder);
+                return;
             case AvroSchema::UNION_SCHEMA:
-                return $this->writeUnion($writers_schema, $datum, $encoder);
+                $this->writeUnion($writers_schema, $datum, $encoder);
+                return;
             default:
                 throw new AvroException(sprintf(
                     'Unknown type: %s',
@@ -121,12 +136,28 @@ class AvroIODatumWriter
         }
     }
 
+    private function writeBytes(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        $logicalType = $writers_schema->logicalType();
+        if (
+            $logicalType instanceof AvroLogicalType
+            && $logicalType->name() === AvroSchema::DECIMAL_LOGICAL_TYPE
+        ) {
+            $scale = $logicalType->attributes()['scale'] ?? 0;
+            $precision = $logicalType->attributes()['precision'] ?? null;
+
+            $encoder->writeDecimal($datum, $scale, $precision);
+            return;
+        }
+
+        $encoder->writeBytes($datum);
+    }
+
     /**
-     * @param AvroSchema $writers_schema
      * @param null|boolean|int|float|string|array $datum item to be written
-     * @param AvroIOBinaryEncoder $encoder
+     * @throws AvroIOTypeException
      */
-    private function writeArray($writers_schema, $datum, $encoder)
+    private function writeArray(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder): void
     {
         $datum_count = count($datum);
         if (0 < $datum_count) {
@@ -136,16 +167,14 @@ class AvroIODatumWriter
                 $this->writeValidatedData($items, $item, $encoder);
             }
         }
-        return $encoder->writeLong(0);
+        $encoder->writeLong(0);
     }
 
     /**
-     * @param $writers_schema
      * @param $datum
-     * @param $encoder
      * @throws AvroIOTypeException
      */
-    private function writeMap($writers_schema, $datum, $encoder)
+    private function writeMap(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder): void
     {
         $datum_count = count($datum);
         if ($datum_count > 0) {
@@ -158,29 +187,59 @@ class AvroIODatumWriter
         $encoder->writeLong(0);
     }
 
-    private function writeFixed($writers_schema, $datum, $encoder)
+    private function writeFixed(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder): void
     {
-        /**
-         * NOTE Unused $writers_schema parameter included for consistency
-         * with other write_* methods.
-         */
-        return $encoder->write($datum);
+        $logicalType = $writers_schema->logicalType();
+        if (
+            $logicalType instanceof AvroLogicalType
+        ) {
+            switch ($logicalType->name()) {
+                case AvroSchema::DECIMAL_LOGICAL_TYPE:
+                    $scale = $logicalType->attributes()['scale'] ?? 0;
+                    $precision = $logicalType->attributes()['precision'] ?? 
null;
+
+                    $encoder->writeDecimal($datum, $scale, $precision);
+                    return;
+                case AvroSchema::DURATION_LOGICAL_TYPE:
+                    if (!$datum instanceof AvroDuration) {
+                        throw new AvroException(
+                            "Duration datum must be an instance of 
AvroDuration"
+                        );
+                    }
+                    $duration = (string) $datum;
+
+                    if (strlen($duration) !== 12) {
+                        throw new AvroException(
+                            "Fixed duration size mismatch. Expected 12 bytes, 
got " . strlen($duration)
+                        );
+                    }
+
+                    $encoder->write($duration);
+                    return;
+            }
+        }
+
+        $encoder->write($datum);
     }
 
-    private function writeEnum($writers_schema, $datum, $encoder)
+    private function writeEnum(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder): void
     {
         $datum_index = $writers_schema->symbolIndex($datum);
-        return $encoder->writeInt($datum_index);
+        $encoder->writeInt($datum_index);
     }
 
-    private function writeRecord($writers_schema, $datum, $encoder)
+    private function writeRecord(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder): void
     {
         foreach ($writers_schema->fields() as $field) {
             $this->writeValidatedData($field->type(), $datum[$field->name()] 
?? null, $encoder);
         }
     }
 
-    private function writeUnion($writers_schema, $datum, $encoder)
+    /**
+     * @throws AvroIOTypeException
+     * @throws AvroSchemaParseException
+     */
+    private function writeUnion(AvroSchema $writers_schema, $datum, 
AvroIOBinaryEncoder $encoder): void
     {
         $datum_schema_index = -1;
         $datum_schema = null;
diff --git a/lang/php/lib/Datum/Type/AvroDuration.php 
b/lang/php/lib/Datum/Type/AvroDuration.php
new file mode 100644
index 0000000000..7bf40d4a11
--- /dev/null
+++ b/lang/php/lib/Datum/Type/AvroDuration.php
@@ -0,0 +1,114 @@
+<?php
+
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+declare(strict_types=1);
+
+namespace Apache\Avro\Datum\Type;
+
+use Apache\Avro\AvroException;
+use Apache\Avro\Schema\AvroSchema;
+
+class AvroDuration
+{
+    /** @var int */
+    private $months;
+
+    /** @var int */
+    private $days;
+
+    /** @var int */
+    private $milliseconds;
+
+    /**
+     * @throws AvroException
+     */
+    public function __construct(
+        int $months,
+        int $days,
+        int $milliseconds
+    ) {
+        self::validateComponent($months, 'months');
+        self::validateComponent($days, 'days');
+        self::validateComponent($milliseconds, 'milliseconds');
+
+        $this->months = $months;
+        $this->days = $days;
+        $this->milliseconds = $milliseconds;
+    }
+
+    public static function fromBytes(string $bytes): self
+    {
+        $unpackedData = unpack('Vmonths/Vdays/Vmilliseconds', $bytes);
+        $months = (int) $unpackedData['months'];
+        $days = (int) $unpackedData['days'];
+        $milliseconds = (int) $unpackedData['milliseconds'];
+
+        // Correct the sign for each component if it was read as a negative 
number
+        // (i.e., if the value is 2^31 or greater, which means the sign bit 
was set)
+        if ($months > AvroSchema::INT_MAX_VALUE) {
+            $months -= AvroSchema::INT_RANGE;
+        }
+        if ($days > AvroSchema::INT_MAX_VALUE) {
+            $days -= AvroSchema::INT_RANGE;
+        }
+        if ($milliseconds > AvroSchema::INT_MAX_VALUE) {
+            $milliseconds -= AvroSchema::INT_RANGE;
+        }
+
+        return new self(
+            $months,
+            $days,
+            $milliseconds
+        );
+    }
+
+    public function toBytes(): string
+    {
+        $months = pack('V', $this->months);
+        $days = pack('V', $this->days);
+        $milliseconds = pack('V', $this->milliseconds);
+
+        return $months . $days . $milliseconds;
+    }
+
+    public function __toString(): string
+    {
+        return $this->toBytes();
+    }
+
+    /**
+     * Helper to check if a value is within the 32-bit signed range.
+     * @throws AvroException If the value is out of bounds.
+     */
+    private static function validateComponent(int $value, string $name): void
+    {
+        if ($value > AvroSchema::INT_MAX_VALUE || $value < 
AvroSchema::INT_MIN_VALUE) {
+            // Throw an appropriate exception for an out-of-bounds value
+            // You may need to replace \Exception with your specific 
AvroException
+            throw new AvroException(
+                sprintf(
+                    "Duration component '%s' value (%d) is out of bounds for a 
32-bit signed integer.",
+                    $name,
+                    $value
+                )
+            );
+        }
+    }
+}
diff --git a/lang/php/lib/Schema/AvroFixedSchema.php 
b/lang/php/lib/Schema/AvroFixedSchema.php
index 3ace00e46b..a48073ad36 100644
--- a/lang/php/lib/Schema/AvroFixedSchema.php
+++ b/lang/php/lib/Schema/AvroFixedSchema.php
@@ -20,6 +20,8 @@
 
 namespace Apache\Avro\Schema;
 
+use Apache\Avro\AvroException;
+
 /**
  * AvroNamedSchema with fixed-length data values
  * @package Avro
@@ -67,4 +69,55 @@ class AvroFixedSchema extends AvroNamedSchema
         $avro[AvroSchema::SIZE_ATTR] = $this->size;
         return $avro;
     }
+
+    /**
+     * @param array<int, string>|null $aliases
+     * @throws AvroSchemaParseException
+     */
+    public static function duration(
+        AvroName $name,
+        ?string $doc,
+        AvroNamedSchemata &$schemata = null,
+        ?array $aliases = null
+    ): self {
+        $fixedSchema = new self($name, $doc, 12, $schemata, $aliases);
+
+        $fixedSchema->logicalType = AvroLogicalType::duration();
+
+        return $fixedSchema;
+    }
+
+    /**
+     * @param array<int, string>|null $aliases
+     * @throws AvroSchemaParseException
+     * @throws AvroException
+     */
+    public static function decimal(
+        AvroName $name,
+        ?string $doc,
+        int $size,
+        int $precision,
+        int $scale,
+        ?AvroNamedSchemata &$schemata = null,
+        ?array $aliases = null
+    ): self {
+        $self = new self($name, $doc, $size, $schemata, $aliases);
+
+        $maxPrecision = (int) floor(log10(self::maxDecimalMagnitude($size)));
+
+        if ($precision > $maxPrecision) {
+            throw new AvroException(
+                "Invalid precision for specified fixed size (size='{$size}', 
precision='{$precision}')."
+            );
+        }
+
+        $self->logicalType = AvroLogicalType::decimal($precision, $scale);
+
+        return $self;
+    }
+
+    public static function maxDecimalMagnitude(int $size): float
+    {
+        return (float) (2 ** ((8 * $size) - 1)) - 1;
+    }
 }
diff --git a/lang/php/lib/Schema/AvroLogicalType.php 
b/lang/php/lib/Schema/AvroLogicalType.php
new file mode 100644
index 0000000000..9b19794473
--- /dev/null
+++ b/lang/php/lib/Schema/AvroLogicalType.php
@@ -0,0 +1,128 @@
+<?php
+
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+namespace Apache\Avro\Schema;
+
+use Apache\Avro\AvroException;
+
+class AvroLogicalType
+{
+    public const ATTRIBUTE_DECIMAL_PRECISION = 'precision';
+    public const ATTRIBUTE_DECIMAL_SCALE = 'scale';
+
+    /** @var string */
+    private $name;
+
+    /** @var array */
+    private $attributes;
+
+    public function __construct(string $name, array $attributes = [])
+    {
+        $this->name = $name;
+        $this->attributes = $attributes;
+    }
+
+    public function name(): string
+    {
+        return $this->name;
+    }
+
+    public function attributes(): array
+    {
+        return $this->attributes;
+    }
+
+    public function toAvro(): array
+    {
+        $avro[AvroSchema::LOGICAL_TYPE_ATTR] = $this->name;
+        return array_merge($avro, $this->attributes);
+    }
+
+    public static function decimal(int $precision, int $scale): self
+    {
+        if ($precision <= 0) {
+            throw new AvroException("Precision '{$precision}' is invalid. It 
must be a positive integer.");
+        }
+
+        if ($scale < 0) {
+            throw new AvroException("Scale '{$scale}' is invalid. It must be a 
non-negative integer.");
+        }
+
+        if ($scale >= $precision) {
+            throw new AvroException(
+                "Scale must be a lower than precision (scale='{$scale}', 
precision='{$precision}')."
+            );
+        }
+
+        return new AvroLogicalType(
+            AvroSchema::DECIMAL_LOGICAL_TYPE,
+            [
+                self::ATTRIBUTE_DECIMAL_PRECISION => $precision,
+                self::ATTRIBUTE_DECIMAL_SCALE => $scale,
+
+            ]
+        );
+    }
+
+    public static function uuid(): self
+    {
+        return new AvroLogicalType(AvroSchema::UUID_LOGICAL_TYPE);
+    }
+
+    public static function date(): self
+    {
+        return new AvroLogicalType(AvroSchema::DATE_LOGICAL_TYPE);
+    }
+
+    public static function timeMillis(): self
+    {
+        return new AvroLogicalType(AvroSchema::TIME_MILLIS_LOGICAL_TYPE);
+    }
+
+    public static function timeMicros(): self
+    {
+        return new AvroLogicalType(AvroSchema::TIME_MICROS_LOGICAL_TYPE);
+    }
+
+    public static function timestampMillis(): self
+    {
+        return new AvroLogicalType(AvroSchema::TIMESTAMP_MILLIS_LOGICAL_TYPE);
+    }
+
+    public static function timestampMicros(): self
+    {
+        return new AvroLogicalType(AvroSchema::TIMESTAMP_MICROS_LOGICAL_TYPE);
+    }
+
+    public static function localTimestampMillis(): self
+    {
+        return new 
AvroLogicalType(AvroSchema::LOCAL_TIMESTAMP_MILLIS_LOGICAL_TYPE);
+    }
+
+    public static function localTimestampMicros(): self
+    {
+        return new 
AvroLogicalType(AvroSchema::LOCAL_TIMESTAMP_MICROS_LOGICAL_TYPE);
+    }
+
+    public static function duration(): self
+    {
+        return new AvroLogicalType(AvroSchema::DURATION_LOGICAL_TYPE);
+    }
+}
diff --git a/lang/php/lib/Schema/AvroPrimitiveSchema.php 
b/lang/php/lib/Schema/AvroPrimitiveSchema.php
index 94686d8ec0..1c331a3037 100644
--- a/lang/php/lib/Schema/AvroPrimitiveSchema.php
+++ b/lang/php/lib/Schema/AvroPrimitiveSchema.php
@@ -39,16 +39,90 @@ class AvroPrimitiveSchema extends AvroSchema
         parent::__construct($type);
     }
 
+    public static function decimal(int $precision, int $scale): self
+    {
+        $self = new self(AvroSchema::BYTES_TYPE);
+        $self->logicalType = AvroLogicalType::decimal($precision, $scale);
+
+        return $self;
+    }
+
+    public static function uuid(): self
+    {
+        $self = new self(AvroSchema::STRING_TYPE);
+        $self->logicalType = AvroLogicalType::uuid();
+
+        return $self;
+    }
+
+    public static function date(): self
+    {
+        $self = new self(AvroSchema::INT_TYPE);
+        $self->logicalType = AvroLogicalType::date();
+
+        return $self;
+    }
+
+    public static function timeMillis(): self
+    {
+        $self = new self(AvroSchema::INT_TYPE);
+        $self->logicalType = AvroLogicalType::timeMillis();
+
+        return $self;
+    }
+
+    public static function timeMicros(): self
+    {
+        $self = new self(AvroSchema::LONG_TYPE);
+        $self->logicalType = AvroLogicalType::timeMicros();
+
+        return $self;
+    }
+
+    public static function timestampMillis(): self
+    {
+        $self = new self(AvroSchema::LONG_TYPE);
+        $self->logicalType = AvroLogicalType::timestampMillis();
+
+        return $self;
+    }
+
+    public static function timestampMicros(): self
+    {
+        $self = new self(AvroSchema::LONG_TYPE);
+        $self->logicalType = AvroLogicalType::timestampMicros();
+
+        return $self;
+    }
+
+    public static function localTimestampMillis(): self
+    {
+        $self = new self(AvroSchema::LONG_TYPE);
+        $self->logicalType = AvroLogicalType::localTimestampMillis();
+
+        return $self;
+    }
+
+    public static function localTimestampMicros(): self
+    {
+        $self = new self(AvroSchema::LONG_TYPE);
+        $self->logicalType = AvroLogicalType::localTimestampMicros();
+
+        return $self;
+    }
+
     /**
      * @returns mixed
      */
     public function toAvro()
     {
         $avro = parent::toAvro();
+
         // FIXME: Is this if really necessary? When *wouldn't* this be the 
case?
         if (1 == count($avro)) {
             return $this->type;
         }
+
         return $avro;
     }
 }
diff --git a/lang/php/lib/Schema/AvroRecordSchema.php 
b/lang/php/lib/Schema/AvroRecordSchema.php
index 354aebd2a8..959ce5904e 100644
--- a/lang/php/lib/Schema/AvroRecordSchema.php
+++ b/lang/php/lib/Schema/AvroRecordSchema.php
@@ -110,7 +110,11 @@ class AvroRecordSchema extends AvroNamedSchema
             ) {
                 $is_schema_from_schemata = true;
             } else {
-                $field_schema = self::subparse($type, $default_namespace, 
$schemata);
+                if (self::isPrimitiveType($type)) {
+                    $field_schema = self::subparse($field, $default_namespace, 
$schemata);
+                } else {
+                    $field_schema = self::subparse($type, $default_namespace, 
$schemata);
+                }
             }
 
             $new_field = new AvroField(
diff --git a/lang/php/lib/Schema/AvroSchema.php 
b/lang/php/lib/Schema/AvroSchema.php
index dc4bb8e6e1..5a33ce1c9e 100644
--- a/lang/php/lib/Schema/AvroSchema.php
+++ b/lang/php/lib/Schema/AvroSchema.php
@@ -21,6 +21,7 @@
 namespace Apache\Avro\Schema;
 
 use Apache\Avro\AvroUtil;
+use Apache\Avro\Datum\Type\AvroDuration;
 
 /** TODO
  * - ARRAY have only type and item attributes (what about metadata?)
@@ -64,12 +65,17 @@ class AvroSchema
     public const INT_MAX_VALUE = 2147483647;
 
     /**
-     * @var long lower bound of long values: -(1 << 63)
+     * @var int upper bound of integer values: (1 << 31) - 1
+     */
+    public const INT_RANGE = 4294967296;
+
+    /**
+     * @var int lower bound of long values: -(1 << 63)
      */
     public const LONG_MIN_VALUE = -9223372036854775808;
 
     /**
-     * @var long upper bound of long values: (1 << 63) - 1
+     * @var int upper bound of long values: (1 << 63) - 1
      */
     public const LONG_MAX_VALUE = 9223372036854775807;
 
@@ -119,6 +125,37 @@ class AvroSchema
      */
     public const BYTES_TYPE = 'bytes';
 
+    // Logical Types
+    /** @var string */
+    public const DECIMAL_LOGICAL_TYPE = 'decimal';
+
+    /** @var string */
+    public const UUID_LOGICAL_TYPE = 'uuid';
+
+    /** @var string  */
+    public const DATE_LOGICAL_TYPE = 'date';
+
+    /** @var string */
+    public const TIME_MILLIS_LOGICAL_TYPE = 'time-millis';
+
+    /** @var string */
+    public const TIME_MICROS_LOGICAL_TYPE = 'time-micros';
+
+    /** @var string */
+    public const TIMESTAMP_MILLIS_LOGICAL_TYPE = 'timestamp-millis';
+
+    /** @var string */
+    public const TIMESTAMP_MICROS_LOGICAL_TYPE = 'timestamp-micros';
+
+    /** @var string */
+    public const LOCAL_TIMESTAMP_MILLIS_LOGICAL_TYPE = 
'local-timestamp-millis';
+
+    /** @var string */
+    public const LOCAL_TIMESTAMP_MICROS_LOGICAL_TYPE = 
'local-timestamp-micros';
+
+    /** @var string */
+    public const DURATION_LOGICAL_TYPE = 'duration';
+
     // Complex Types
     // Unnamed Schema
     /**
@@ -225,6 +262,9 @@ class AvroSchema
     /** @var string aliases string attribute name */
     public const ALIASES_ATTR = 'aliases';
 
+    /** @var string logical type attribute name */
+    public const LOGICAL_TYPE_ATTR = 'logicalType';
+
     /**
      * @var array list of primitive schema type names
      */
@@ -259,13 +299,17 @@ class AvroSchema
         self::ITEMS_ATTR,
         self::SIZE_ATTR,
         self::SYMBOLS_ATTR,
-        self::VALUES_ATTR
+        self::VALUES_ATTR,
+        self::LOGICAL_TYPE_ATTR,
     );
     /**
      * @var string|AvroNamedSchema
      */
     public $type;
 
+    /** @var null|AvroLogicalType */
+    protected $logicalType = null;
+
     /**
      * @param string $type a schema type name
      * @internal Should only be called from within the constructor of
@@ -304,7 +348,29 @@ class AvroSchema
             $type = $avro[self::TYPE_ATTR] ?? null;
 
             if (self::isPrimitiveType($type)) {
-                return new AvroPrimitiveSchema($type);
+                switch ($avro[self::LOGICAL_TYPE_ATTR] ?? null) {
+                    case self::DECIMAL_LOGICAL_TYPE:
+                        [$precision, $scale] = 
self::extractPrecisionAndScaleForDecimal($avro);
+                        return AvroPrimitiveSchema::decimal($precision, 
$scale);
+                    case self::UUID_LOGICAL_TYPE:
+                        return AvroPrimitiveSchema::uuid();
+                    case self::DATE_LOGICAL_TYPE:
+                        return AvroPrimitiveSchema::date();
+                    case self::TIME_MILLIS_LOGICAL_TYPE:
+                        return AvroPrimitiveSchema::timeMillis();
+                    case self::TIME_MICROS_LOGICAL_TYPE:
+                        return AvroPrimitiveSchema::timeMicros();
+                    case self::TIMESTAMP_MILLIS_LOGICAL_TYPE:
+                        return AvroPrimitiveSchema::timestampMillis();
+                    case self::TIMESTAMP_MICROS_LOGICAL_TYPE:
+                        return AvroPrimitiveSchema::timestampMicros();
+                    case self::LOCAL_TIMESTAMP_MILLIS_LOGICAL_TYPE:
+                        return AvroPrimitiveSchema::localTimestampMillis();
+                    case self::LOCAL_TIMESTAMP_MICROS_LOGICAL_TYPE:
+                        return AvroPrimitiveSchema::localTimestampMicros();
+                    default:
+                        return new AvroPrimitiveSchema($type);
+                }
             }
 
             if (self::isNamedType($type)) {
@@ -316,6 +382,29 @@ class AvroSchema
                 switch ($type) {
                     case self::FIXED_SCHEMA:
                         $size = $avro[self::SIZE_ATTR] ?? null;
+                        if (array_key_exists(self::LOGICAL_TYPE_ATTR, $avro)) {
+                            switch ($avro[self::LOGICAL_TYPE_ATTR]) {
+                                case self::DURATION_LOGICAL_TYPE:
+                                    return AvroFixedSchema::duration(
+                                        $new_name,
+                                        $doc,
+                                        $schemata,
+                                        $aliases
+                                    );
+                                case self::DECIMAL_LOGICAL_TYPE:
+                                    [$precision, $scale] = 
self::extractPrecisionAndScaleForDecimal($avro);
+                                    return AvroFixedSchema::decimal(
+                                        $new_name,
+                                        $doc,
+                                        $size,
+                                        $precision,
+                                        $scale,
+                                        $schemata,
+                                        $aliases
+                                    );
+                            }
+                        }
+
                         return new AvroFixedSchema(
                             $new_name,
                             $doc,
@@ -450,7 +539,7 @@ class AvroSchema
      *                  and false otherwise.
      * @throws AvroSchemaParseException
      */
-    public static function isValidDatum($expected_schema, $datum)
+    public static function isValidDatum(AvroSchema $expected_schema, $datum): 
bool
     {
         switch ($expected_schema->type) {
             case self::NULL_TYPE:
@@ -504,8 +593,28 @@ class AvroSchema
             case self::ENUM_SCHEMA:
                 return in_array($datum, $expected_schema->symbols());
             case self::FIXED_SCHEMA:
+                if (
+                    $expected_schema->logicalType() instanceof AvroLogicalType
+                ) {
+                    switch ($expected_schema->logicalType->name()) {
+                        case self::DECIMAL_LOGICAL_TYPE:
+                            $value = abs((float) $datum);
+                            $maxMagnitude = 
AvroFixedSchema::maxDecimalMagnitude((int) $expected_schema->size());
+                            return $value <= $maxMagnitude;
+                        case self::DURATION_LOGICAL_TYPE:
+                            return $datum instanceof AvroDuration;
+                        default:
+                            throw new AvroSchemaParseException(
+                                sprintf(
+                                    'Logical type %s not supported for fixed 
schema validation.',
+                                    $expected_schema->logicalType->name()
+                                )
+                            );
+                    }
+                }
+
                 return (is_string($datum)
-                    && (strlen($datum) == $expected_schema->size()));
+                    && (strlen($datum) === $expected_schema->size()));
             case self::RECORD_SCHEMA:
             case self::ERROR_SCHEMA:
             case self::REQUEST_SCHEMA:
@@ -548,13 +657,18 @@ class AvroSchema
     }
 
     /**
-     * @returns string schema type name of this schema
+     * @returns string|AvroNamedSchema schema type name of this schema
      */
     public function type()
     {
         return $this->type;
     }
 
+    public function logicalType(): ?AvroLogicalType
+    {
+        return $this->logicalType;
+    }
+
     /**
      * @returns string the JSON-encoded representation of this Avro schema.
      */
@@ -563,12 +677,15 @@ class AvroSchema
         return (string) json_encode($this->toAvro());
     }
 
-    /**
-     * @returns mixed
-     */
     public function toAvro()
     {
-        return array(self::TYPE_ATTR => $this->type);
+        $avro = [self::TYPE_ATTR => $this->type];
+
+        if (!is_null($this->logicalType)) {
+            $avro = array_merge($avro, $this->logicalType->toAvro());
+        }
+
+        return $avro;
     }
 
     /**
@@ -578,4 +695,26 @@ class AvroSchema
     {
         return $this->$attribute();
     }
+
+    /**
+     * @return array{0: int, 1: int} [precision, scale]
+     * @throws AvroSchemaParseException
+     */
+    private static function extractPrecisionAndScaleForDecimal(array $avro): 
array
+    {
+        $precision = $avro[AvroLogicalType::ATTRIBUTE_DECIMAL_PRECISION] ?? 
null;
+        if (!is_int($precision)) {
+            throw new AvroSchemaParseException(
+                "Invalid value '{$precision}' for 'precision' attribute of 
decimal logical type."
+            );
+        }
+        $scale = $avro[AvroLogicalType::ATTRIBUTE_DECIMAL_SCALE] ?? 0;
+        if (!is_int($scale)) {
+            throw new AvroSchemaParseException(
+                "Invalid value '{$scale}' for 'scale' attribute of decimal 
logical type."
+            );
+        }
+
+        return [$precision, $scale];
+    }
 }
diff --git a/lang/php/phpunit.xml b/lang/php/phpunit.xml
index bdf201da73..10584bc4aa 100644
--- a/lang/php/phpunit.xml
+++ b/lang/php/phpunit.xml
@@ -19,7 +19,7 @@
          
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.1/phpunit.xsd";
          bootstrap="test/test_helper.php"
          executionOrder="depends,defects"
-         forceCoversAnnotation="true"
+         forceCoversAnnotation="false"
          beStrictAboutCoversAnnotation="true"
          beStrictAboutOutputDuringTests="true"
          beStrictAboutTodoAnnotatedTests="true"
diff --git a/lang/php/test/DatumIOTest.php b/lang/php/test/DatumIOTest.php
index b2b879c1c8..a29cf06f06 100644
--- a/lang/php/test/DatumIOTest.php
+++ b/lang/php/test/DatumIOTest.php
@@ -20,10 +20,12 @@
 namespace Apache\Avro\Tests;
 
 use Apache\Avro\AvroDebug;
+use Apache\Avro\AvroException;
 use Apache\Avro\Datum\AvroIOBinaryDecoder;
 use Apache\Avro\Datum\AvroIOBinaryEncoder;
 use Apache\Avro\Datum\AvroIODatumReader;
 use Apache\Avro\Datum\AvroIODatumWriter;
+use Apache\Avro\Datum\Type\AvroDuration;
 use Apache\Avro\IO\AvroStringIO;
 use Apache\Avro\Schema\AvroSchema;
 use PHPUnit\Framework\TestCase;
@@ -39,157 +41,380 @@ class DatumIOTest extends TestCase
     /**
      * @dataProvider data_provider
      */
-    function test_datum_round_trip($schema_json, $datum, $binary)
+    public function test_datum_round_trip(string $schema_json, $datum, string 
$binary): void
     {
-        $schema = AvroSchema::parse($schema_json);
-        $written = new AvroStringIO();
-        $encoder = new AvroIOBinaryEncoder($written);
-        $writer = new AvroIODatumWriter($schema);
-
-        $writer->write($datum, $encoder);
-        $output = (string) $written;
-        $this->assertEquals($binary, $output,
-            sprintf("expected: %s\n  actual: %s",
-                AvroDebug::asciiString($binary, 'hex'),
-                AvroDebug::asciiString($output, 'hex')));
-
-        $read = new AvroStringIO($binary);
-        $decoder = new AvroIOBinaryDecoder($read);
-        $reader = new AvroIODatumReader($schema);
-        $read_datum = $reader->read($decoder);
-        $this->assertEquals($datum, $read_datum);
+        $this->assertIsValidDatumForSchema($schema_json, $datum, $binary);
     }
 
-    function data_provider()
+    public static function data_provider(): array
     {
-        return array(
-            array('"null"', null, ''),
-
-            array('"boolean"', true, "\001"),
-            array('"boolean"', false, "\000"),
-
-            array('"int"', (int) -2147483648, "\xFF\xFF\xFF\xFF\x0F"),
-            array('"int"', -1, "\001"),
-            array('"int"', 0, "\000"),
-            array('"int"', 1, "\002"),
-            array('"int"', 2147483647, "\xFE\xFF\xFF\xFF\x0F"),
-
-            array('"long"', (int) -9223372036854775808, 
"\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x01"),
-            array('"long"', -(1<<62), "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x7F"),
-            array('"long"', -(1<<61), "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x3F"),
-            array('"long"', -4294967295, "\xFD\xFF\xFF\xFF\x1F"),
-            array('"long"', -1<<24, "\xFF\xFF\xFF\x0F"),
-            array('"long"', -1<<16, "\xFF\xFF\x07"),
-            array('"long"', -255, "\xFD\x03"),
-            array('"long"', -128, "\xFF\x01"),
-            array('"long"', -127, "\xFD\x01"),
-            array('"long"', -10, "\x13"),
-            array('"long"', -3, "\005"),
-            array('"long"', -2, "\003"),
-            array('"long"', -1, "\001"),
-            array('"long"',  0, "\000"),
-            array('"long"',  1, "\002"),
-            array('"long"',  2, "\004"),
-            array('"long"',  3, "\006"),
-            array('"long"', 10, "\x14"),
-            array('"long"', 127, "\xFE\x01"),
-            array('"long"', 128, "\x80\x02"),
-            array('"long"', 255, "\xFE\x03"),
-            array('"long"', 1<<16, "\x80\x80\x08"),
-            array('"long"', 1<<24, "\x80\x80\x80\x10"),
-            array('"long"', 4294967295, "\xFE\xFF\xFF\xFF\x1F"),
-            array('"long"', 1<<61, "\x80\x80\x80\x80\x80\x80\x80\x80\x40"),
-            array('"long"', 1<<62, "\x80\x80\x80\x80\x80\x80\x80\x80\x80\x01"),
-            array('"long"', 9223372036854775807, 
"\xFE\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x01"),
-
-            array('"float"', (float) -10.0, "\000\000 \301"),
-            array('"float"', (float) -1.0, "\000\000\200\277"),
-            array('"float"', (float) 0.0, "\000\000\000\000"),
-            array('"float"', (float) 2.0, "\000\000\000@"),
-            array('"float"', (float) 9.0, "\000\000\020A"),
-
-            array('"double"', (double) -10.0, "\000\000\000\000\000\000$\300"),
-            array('"double"', (double) -1.0, 
"\000\000\000\000\000\000\360\277"),
-            array('"double"', (double) 0.0, 
"\000\000\000\000\000\000\000\000"),
-            array('"double"', (double) 2.0, "\000\000\000\000\000\000\000@"),
-            array('"double"', (double) 9.0, "\000\000\000\000\000\000\"@"),
-
-            array('"string"', 'foo', "\x06foo"),
-            array('"bytes"', "\x01\x02\x03", "\x06\x01\x02\x03"),
-
-            array(
+        return [
+            ['"null"', null, ''],
+
+            ['"boolean"', true, "\001"],
+            ['"boolean"', false, "\000"],
+
+            ['"int"', (int) -2147483648, "\xFF\xFF\xFF\xFF\x0F"],
+            ['"int"', -1, "\001"],
+            ['"int"', 0, "\000"],
+            ['"int"', 1, "\002"],
+            ['"int"', 2147483647, "\xFE\xFF\xFF\xFF\x0F"],
+
+            ['"long"', (int) -9223372036854775808, 
"\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x01"],
+            ['"long"', -(1<<62), "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x7F"],
+            ['"long"', -(1<<61), "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x3F"],
+            ['"long"', -4294967295, "\xFD\xFF\xFF\xFF\x1F"],
+            ['"long"', -1<<24, "\xFF\xFF\xFF\x0F"],
+            ['"long"', -1<<16, "\xFF\xFF\x07"],
+            ['"long"', -255, "\xFD\x03"],
+            ['"long"', -128, "\xFF\x01"],
+            ['"long"', -127, "\xFD\x01"],
+            ['"long"', -10, "\x13"],
+            ['"long"', -3, "\005"],
+            ['"long"', -2, "\003"],
+            ['"long"', -1, "\001"],
+            ['"long"',  0, "\000"],
+            ['"long"',  1, "\002"],
+            ['"long"',  2, "\004"],
+            ['"long"',  3, "\006"],
+            ['"long"', 10, "\x14"],
+            ['"long"', 127, "\xFE\x01"],
+            ['"long"', 128, "\x80\x02"],
+            ['"long"', 255, "\xFE\x03"],
+            ['"long"', 1<<16, "\x80\x80\x08"],
+            ['"long"', 1<<24, "\x80\x80\x80\x10"],
+            ['"long"', 4294967295, "\xFE\xFF\xFF\xFF\x1F"],
+            ['"long"', 1<<61, "\x80\x80\x80\x80\x80\x80\x80\x80\x40"],
+            ['"long"', 1<<62, "\x80\x80\x80\x80\x80\x80\x80\x80\x80\x01"],
+            ['"long"', 9223372036854775807, 
"\xFE\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x01"],
+
+            ['"float"', (float) -10.0, "\000\000 \301"],
+            ['"float"', (float) -1.0, "\000\000\200\277"],
+            ['"float"', (float) 0.0, "\000\000\000\000"],
+            ['"float"', (float) 2.0, "\000\000\000@"],
+            ['"float"', (float) 9.0, "\000\000\020A"],
+
+            ['"double"', (double) -10.0, "\000\000\000\000\000\000$\300"],
+            ['"double"', (double) -1.0, "\000\000\000\000\000\000\360\277"],
+            ['"double"', (double) 0.0, "\000\000\000\000\000\000\000\000"],
+            ['"double"', (double) 2.0, "\000\000\000\000\000\000\000@"],
+            ['"double"', (double) 9.0, "\000\000\000\000\000\000\"@"],
+
+            ['"string"', 'foo', "\x06foo"],
+            ['"bytes"', "\x01\x02\x03", "\x06\x01\x02\x03"],
+
+            [
                 '{"type":"array","items":"int"}',
-                array(1, 2, 3),
+                [1, 2, 3],
                 "\x06\x02\x04\x06\x00"
-            ),
-            array(
+            ],
+            [
                 '{"type":"map","values":"int"}',
-                array('foo' => 1, 'bar' => 2, 'baz' => 3),
+                ['foo' => 1, 'bar' => 2, 'baz' => 3],
                 "\x06\x06foo\x02\x06bar\x04\x06baz\x06\x00"
-            ),
-            array('["null", "int"]', 1, "\x02\x02"),
-            array(
+            ],
+            ['["null", "int"]', 1, "\x02\x02"],
+            [
                 '{"name":"fix","type":"fixed","size":3}',
                 "\xAA\xBB\xCC",
                 "\xAA\xBB\xCC"
-            ),
-            array(
+            ],
+            [
                 '{"name":"enm","type":"enum","symbols":["A","B","C"]}',
                 'B',
                 "\x02"
-            ),
-            array(
+            ],
+            [
                 
'{"name":"rec","type":"record","fields":[{"name":"a","type":"int"},{"name":"b","type":"boolean"}]}',
-                array('a' => 1, 'b' => false),
+                ['a' => 1, 'b' => false],
                 "\x02\x00"
-            )
+            ],
+        ];
+    }
+
+    public static function validBytesDecimalLogicalType(): array
+    {
+        return [
+            'positive' => [
+                '10.95',
+                4,
+                2,
+                "\x04\x04\x47",
+            ],
+            'negative' => [
+                '-10.95',
+                4,
+                2,
+                "\x04\xFB\xB9",
+            ],
+            'small positive' => [
+                '0.05',
+                3,
+                2,
+                "\x02\x05",
+            ],
+            'zero value' => [
+                '0',
+                4,
+                2,
+                "\x02\x00",
+            ],
+            'unscaled positive' => [
+                '12345',
+                6,
+                0,
+                "\x04\x30\x39",
+            ],
+            'trimming edge case 127' => [
+                '127',
+                3,
+                0,
+                "\x02\x7F",
+            ],
+            'trimming edge case 128' => [
+                '128',
+                3,
+                0,
+                "\x04\x00\x80",
+            ],
+            'negative trimming -1' => [
+                '-1',
+                3,
+                0,
+                "\x02\xFF",
+            ],
+            'negative trimming -129' => [
+                '-129',
+                3,
+                0,
+                "\x04\xFF\x7F",
+            ],
+            'high precision positive number' => [
+                '549755813887',
+                12,
+                0,
+                "\x0A\x7F\xFF\xFF\xFF\xFF",
+            ],
+            'high precision positive number with scale' => [
+                '54975581.3887',
+                12,
+                4,
+                "\x0A\x7F\xFF\xFF\xFF\xFF",
+            ],
+            'high precision negative number' => [
+                '-549755813888',
+                12,
+                0,
+                "\x0A\x80\x00\x00\x00\x00",
+            ],
+            'high precision negative number with scale' => [
+                '-54975581.3888',
+                12,
+                4,
+                "\x0A\x80\x00\x00\x00\x00",
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider validBytesDecimalLogicalType
+     */
+    public function testValidBytesDecimalLogicalType(string $datum, int 
$precision, int $scale, string $expected): void
+    {
+        $bytesSchemaJson = <<<JSON
+        {
+          "name": "number",
+          "type": "bytes",
+          "logicalType": "decimal",
+          "precision": {$precision},
+          "scale": {$scale}
+        }
+        JSON;
+
+        $this->assertIsValidDatumForSchema($bytesSchemaJson, $datum, 
$expected);
+
+        $fixedSchemaJson = <<<JSON
+        {
+          "name": "number",
+          "type": "fixed",
+          "size": 8,
+          "logicalType": "decimal",
+          "precision": {$precision},
+          "scale": {$scale}
+        }
+        JSON;
+
+        $this->assertIsValidDatumForSchema($fixedSchemaJson, $datum, 
$expected);
+    }
+
+    public function testInvalidBytesLogicalTypeOutOfRange(): void
+    {
+        $schemaJson = <<<JSON
+        {
+          "name": "number",
+          "type": "bytes",
+          "logicalType": "decimal",
+          "precision": 4,
+          "scale": 0
+        }
+        JSON;
+
+        $schema = AvroSchema::parse($schemaJson);
+        $written = new AvroStringIO();
+        $encoder = new AvroIOBinaryEncoder($written);
+        $writer = new AvroIODatumWriter($schema);
+
+        $this->expectException(AvroException::class);
+        $this->expectExceptionMessage("Decimal value '10000' is out of range 
for precision=4, scale=0");
+        $writer->write("10000", $encoder);
+    }
+
+    public static function validDurationLogicalTypes(): array
+    {
+        return [
+            [new AvroDuration(1, 2, 3)],
+            [new AvroDuration(-1, -2, -3)],
+            [new AvroDuration(AvroSchema::INT_MAX_VALUE, 
AvroSchema::INT_MAX_VALUE, AvroSchema::INT_MIN_VALUE)],
+        ];
+    }
+
+    /**
+     * @dataProvider validDurationLogicalTypes
+     */
+    public function testDurationLogicalType(AvroDuration $avroDuration): void
+    {
+        $bytesSchemaJson = <<<JSON
+        {
+          "name": "number",
+          "type": "fixed",
+          "size": 12,
+          "logicalType": "duration"
+        }
+        JSON;
+
+        $this->assertIsValidDatumForSchema(
+            $bytesSchemaJson,
+            $avroDuration,
+            $avroDuration
         );
     }
 
-    function default_provider()
+    public static function durationLogicalTypeOutOfBounds(): array
+    {
+        return [
+            [AvroSchema::INT_MIN_VALUE - 1, 0, 0],
+            [0, AvroSchema::INT_MIN_VALUE - 1, 0],
+            [0, 0, AvroSchema::INT_MIN_VALUE - 1],
+            [AvroSchema::INT_MAX_VALUE + 1, 0, 0],
+            [0, AvroSchema::INT_MAX_VALUE + 1, 0],
+            [0, 0, AvroSchema::INT_MAX_VALUE + 1],
+        ];
+    }
+
+    /**
+     * @dataProvider durationLogicalTypeOutOfBounds
+     */
+    public function testDurationLogicalTypeOutOfBounds(int $months, int $days, 
int $milliseconds): void
+    {
+        $this->expectException(AvroException::class);
+        new AvroDuration($months, $days, $milliseconds);
+    }
+
+    public static function default_provider(): array
     {
-        return array(
-            array('"null"', 'null', null),
-            array('"boolean"', 'true', true),
-            array('"int"', '1', 1),
-            array('"long"', '2000', 2000),
-            array('"float"', '1.1', (float) 1.1),
-            array('"double"', '200.2', (double) 200.2),
-            array('"string"', '"quux"', 'quux'),
-            array('"bytes"', '"\u00FF"', "\xC3\xBF"),
-            array(
+        return [
+            [
+                '"null"',
+                'null',
+                null,
+            ],
+            [
+                '"boolean"',
+                'true',
+                true,
+            ],
+            [
+                '"int"',
+                '1',
+                1,
+            ],
+            [
+                '"long"',
+                '2000',
+                2000,
+            ],
+            [
+                '"float"',
+                '1.1',
+                1.1,
+            ],
+            [
+                '"double"',
+                '200.2',
+                200.2,
+            ],
+            [
+                '"string"',
+                '"quux"',
+                'quux',
+            ],
+            [
+                '"bytes"',
+                '"\u00FF"',
+                "\xC3\xBF"],
+            [
                 '{"type":"array","items":"int"}',
                 '[5,4,3,2]',
-                array(5, 4, 3, 2)
-            ),
-            array(
+                [5, 4, 3, 2]
+            ],
+            [
                 '{"type":"map","values":"int"}',
                 '{"a":9}',
-                array('a' => 9)
-            ),
-            array('["int","string"]', '8', 8),
-            array(
+                ['a' => 9],
+            ],
+            [
+                '["int","string"]',
+                '8',
+                8,
+            ],
+            [
                 '{"name":"x","type":"enum","symbols":["A","V"]}',
                 '"A"',
-                'A'
-            ),
-            array('{"name":"x","type":"fixed","size":4}', '"\u00ff"', 
"\xC3\xBF"),
-            array(
+                'A',
+            ],
+            [
+                '{"name":"x","type":"fixed","size":4}',
+                '"\u00ff"',
+                "\xC3\xBF",
+            ],
+            [
                 
'{"name":"x","type":"record","fields":[{"name":"label","type":"int"}]}',
                 '{"label":7}',
-                array('label' => 7)
-            )
-        );
+                ['label' => 7],
+            ],
+            'logical type' => [
+                '{"type":"string","logicalType":"uuid"}',
+                '"550e8400-e29b-41d4-a716-446655"',
+                '550e8400-e29b-41d4-a716-446655',
+
+            ],
+            'logical type in a record' => [
+                
'{"name":"x","type":"record","fields":[{"name":"price","type":"bytes","logicalType":"decimal","precision":4,"scale":2}]}',
+                '{"price": "\u0000\u0000"}',
+                ['price' => "0"],
+
+            ],
+        ];
     }
 
     /**
      * @dataProvider default_provider
      */
-    function test_field_default_value(
+    public function test_field_default_value(
         $field_schema_json,
         $default_json,
         $default_value
-    ) {
+    ): void {
         $writers_schema_json = '{"name":"foo","type":"record","fields":[]}';
         $writers_schema = AvroSchema::parse($writers_schema_json);
 
@@ -207,4 +432,32 @@ class DatumIOTest extends TestCase
                 print_r($record, true)));
         }
     }
+
+    /**
+     * @param string $schemaJson
+     * @param string $datum
+     * @param string $expected
+     * @return void
+     * @throws \Apache\Avro\IO\AvroIOException
+     */
+    private function assertIsValidDatumForSchema(string $schemaJson, $datum, 
$expected): void
+    {
+        $schema = AvroSchema::parse($schemaJson);
+        $written = new AvroStringIO();
+        $encoder = new AvroIOBinaryEncoder($written);
+        $writer = new AvroIODatumWriter($schema);
+
+        $writer->write($datum, $encoder);
+        $output = (string) $written;
+        $this->assertEquals($expected, $output,
+            sprintf("expected: %s\n  actual: %s",
+                AvroDebug::asciiString($expected, 'hex'),
+                AvroDebug::asciiString($output, 'hex')));
+
+        $read = new AvroStringIO((string) $expected);
+        $decoder = new AvroIOBinaryDecoder($read);
+        $reader = new AvroIODatumReader($schema);
+        $read_datum = $reader->read($decoder);
+        $this->assertEquals($datum, $read_datum);
+    }
 }
diff --git a/lang/php/test/IODatumReaderTest.php 
b/lang/php/test/IODatumReaderTest.php
index acdc8c484d..0135751d80 100644
--- a/lang/php/test/IODatumReaderTest.php
+++ b/lang/php/test/IODatumReaderTest.php
@@ -23,31 +23,43 @@ use Apache\Avro\Datum\AvroIOBinaryDecoder;
 use Apache\Avro\Datum\AvroIOBinaryEncoder;
 use Apache\Avro\Datum\AvroIODatumReader;
 use Apache\Avro\Datum\AvroIODatumWriter;
+use Apache\Avro\Datum\Type\AvroDuration;
 use Apache\Avro\IO\AvroStringIO;
 use Apache\Avro\Schema\AvroSchema;
 use PHPUnit\Framework\TestCase;
 
 class IODatumReaderTest extends TestCase
 {
-    public function testSchemaMatching()
+    public function testSchemaMatching(): void
     {
         $writers_schema = <<<JSON
-      { "type": "map",
-        "values": "bytes" }
-JSON;
+        {
+          "type": "map",
+          "values": "bytes"
+        }
+        JSON;
         $readers_schema = $writers_schema;
         $this->assertTrue(AvroIODatumReader::schemasMatch(
                 AvroSchema::parse($writers_schema),
             AvroSchema::parse($readers_schema)));
     }
 
-    public function test_aliased()
+    public function test_aliased(): void
     {
-        $writers_schema = AvroSchema::parse(<<<SCHEMA
-{"type":"record", "name":"Rec1", "fields":[
-{"name":"field1", "type":"int"}
-]}
-SCHEMA);
+        $writers_schema = AvroSchema::parse(
+            <<<JSON
+            {
+              "type": "record",
+              "name": "Rec1",
+              "fields": [
+                {
+                  "name": "field1",
+                  "type": "int"
+                }
+              ]
+            }
+            JSON
+        );
     $readers_schema = AvroSchema::parse(<<<SCHEMA
       {"type":"record", "name":"Rec2", "aliases":["Rec1"], "fields":[
         {"name":"field2", "aliases":["field1"], "type":"int"}
@@ -69,15 +81,18 @@ SCHEMA);
         $this->assertEquals(['field2' => 1], $record);
     }
 
-    public function testRecordNullField()
+    public function testRecordNullField(): void
     {
-        $schema_json = <<<_JSON
-{"name":"member",
- "type":"record",
- "fields":[{"name":"one", "type":"int"},
-           {"name":"two", "type":["null", "string"]}
-           ]}
-_JSON;
+        $schema_json = <<<JSON
+            {
+              "name":"member",
+              "type":"record",
+              "fields":[
+                {"name":"one", "type":"int"},
+                {"name":"two", "type":["null", "string"]}
+              ]
+            }
+            JSON;
 
         $schema = AvroSchema::parse($schema_json);
         $datum = array("one" => 1);
@@ -91,21 +106,22 @@ _JSON;
         $this->assertSame('0200', bin2hex($bin));
     }
 
-    public function testRecordFieldWithDefault()
-    {
-        $schema = AvroSchema::parse(<<<_JSON
-{
-  "name": "RecordWithDefaultValue",
-  "type": "record",
-  "fields": [
+    public function testRecordFieldWithDefault(): void
     {
-      "name": "field1",
-      "type": "string",
-      "default": "default"
-    }
-  ]
-}
-_JSON
+        $schema = AvroSchema::parse(
+            <<<JSON
+            {
+              "name": "RecordWithDefaultValue",
+              "type": "record",
+              "fields": [
+                {
+                  "name": "field1",
+                  "type": "string",
+                  "default": "default"
+                }
+              ]
+            }
+            JSON
         );
 
         $io = new AvroStringIO();
@@ -122,4 +138,144 @@ _JSON
 
         $this->assertEquals(['field1' => "foobar"], $record);
     }
+
+    public function testRecordWithLogicalTypes(): void
+    {
+        $schema = AvroSchema::parse(
+            <<<JSON
+            {
+              "name": "RecordWithLogicalTypes",
+              "type": "record",
+              "fields": [
+                {
+                  "name": "decimal_field",
+                  "type": "bytes",
+                  "logicalType": "decimal",
+                  "precision": 4,
+                  "scale": 2
+                },
+                {
+                  "name": "uuid_field",
+                  "type": "string",
+                  "logicalType": "uuid"
+                },
+                {
+                  "name": "date_field",
+                  "type": {
+                    "type": "int",
+                    "logicalType": "date"
+                  }
+                },
+                {
+                  "name": "time_millis_field",
+                  "type": {
+                    "type": "int",
+                    "logicalType": "time-millis"
+                  }
+                },
+                {
+                  "name": "time_micros_field",
+                  "type": {
+                    "type": "long",
+                    "logicalType": "time-micros"
+                  }
+                },
+                {
+                  "name": "timestamp_millis_field",
+                  "type": {
+                    "type": "long",
+                    "logicalType": "timestamp-millis"
+                  }
+                },
+                {
+                  "name": "timestamp_micros_field",
+                  "type": {
+                    "type": "long",
+                    "logicalType": "timestamp-micros"
+                  }
+                },
+                {
+                  "name": "local_timestamp_millis_field",
+                  "type": {
+                    "type": "long",
+                    "logicalType": "local-timestamp-millis"
+                 }
+                },
+                {
+                  "name": "local_timestamp_micros_field",
+                  "type": {
+                    "type": "long",
+                    "logicalType": "local-timestamp-micros"
+                 }
+                },
+                {
+                  "name": "duration_field",
+                  "type": {
+                    "name": "duration_field",
+                    "type": "fixed",
+                    "size": 12,
+                    "logicalType": "duration"
+                  }
+                },
+                {
+                  "name": "decimal_fixed_field",
+                  "type": {
+                    "name": "decimal_fixed_field",
+                    "type": "fixed",
+                    "logicalType": "decimal",
+                    "size": 3,
+                    "precision": 4,
+                    "scale": 2
+                  }
+                }
+              ]
+            }
+            JSON
+        );
+
+        $io = new AvroStringIO();
+        $writer = new AvroIODatumWriter();
+        $writer->writeData(
+            $schema,
+            [
+                'decimal_field' => '10.91',
+                'uuid_field' => '9fb9ea49-2f7e-4df3-b02b-96d881e27a6b',
+                'date_field' => 20251023,
+                'time_millis_field' => 86400000,
+                'time_micros_field' => 86400000000,
+                'timestamp_millis_field' => 1761224729109,
+                'timestamp_micros_field' => 1761224729109000,
+                'local_timestamp_millis_field' => 1751224729109,
+                'local_timestamp_micros_field' => 1751224729109000,
+                'duration_field' => new AvroDuration(5, 3600, 1234),
+                'decimal_fixed_field' => '10.91',
+            ],
+            new AvroIOBinaryEncoder($io)
+        );
+
+        $bin = $io->string();
+        $reader = new AvroIODatumReader();
+        $record = $reader->readRecord(
+            $schema,
+            $schema,
+            new AvroIOBinaryDecoder(new AvroStringIO($bin))
+        );
+
+        $this->assertEquals(
+            [
+                'decimal_field' => '10.91',
+                'uuid_field' => '9fb9ea49-2f7e-4df3-b02b-96d881e27a6b',
+                'date_field' => 20251023,
+                'time_millis_field' => 86400000,
+                'time_micros_field' => 86400000000,
+                'timestamp_millis_field' => 1761224729109,
+                'timestamp_micros_field' => 1761224729109000,
+                'local_timestamp_millis_field' => 1751224729109,
+                'local_timestamp_micros_field' => 1751224729109000,
+                'duration_field' => new AvroDuration(5, 3600, 1234),
+                'decimal_fixed_field' => '10.91',
+            ],
+            $record
+        );
+    }
 }
diff --git a/lang/php/test/SchemaTest.php b/lang/php/test/SchemaTest.php
index aaa2240fc1..e0d7be75b0 100644
--- a/lang/php/test/SchemaTest.php
+++ b/lang/php/test/SchemaTest.php
@@ -19,6 +19,7 @@
 
 namespace Apache\Avro\Tests;
 
+use Apache\Avro\AvroException;
 use Apache\Avro\Schema\AvroSchema;
 use Apache\Avro\Schema\AvroSchemaParseException;
 use PHPUnit\Framework\TestCase;
@@ -49,65 +50,65 @@ class SchemaExample
 
 class SchemaTest extends TestCase
 {
-    static $examples = array();
-    static $valid_examples = array();
+    private static $examples = [];
+    private static $valid_examples = [];
 
-    public function test_json_decode()
+    public function test_json_decode(): void
     {
-        $this->assertEquals(json_decode('null', true), null);
-        $this->assertEquals(json_decode('32', true), 32);
-        $this->assertEquals(json_decode('"32"', true), '32');
-        $this->assertEquals((array) json_decode('{"foo": 27}'), array("foo" => 
27));
-        $this->assertTrue(is_array(json_decode('{"foo": 27}', true)));
-        $this->assertEquals(json_decode('{"foo": 27}', true), array("foo" => 
27));
-        $this->assertEquals(json_decode('["bar", "baz", "blurfl"]', true),
-            array("bar", "baz", "blurfl"));
-        $this->assertFalse(is_array(json_decode('null', true)));
-        $this->assertEquals(json_decode('{"type": "null"}', true), 
array("type" => 'null'));
+        $this->assertEquals(null, json_decode('null', true));
+        $this->assertEquals(32, json_decode('32', true));
+        $this->assertEquals('32', json_decode('"32"', true));
+        $this->assertEquals(["foo" => 27], (array) json_decode('{"foo": 27}'));
+        $this->assertIsArray(json_decode('{"foo": 27}', true));
+        $this->assertEquals(["foo" => 27], json_decode('{"foo": 27}', true));
+        $this->assertEquals(["bar", "baz", "blurfl"],
+            json_decode('["bar", "baz", "blurfl"]', true));
+        $this->assertIsNotArray(json_decode('null', true));
+        $this->assertEquals(["type" => 'null'], json_decode('{"type": 
"null"}', true));
 
         // PHP now only accept lowercase true, and rejects TRUE etc.
         // 
https://php.net/manual/en/migration56.incompatible.php#migration56.incompatible.json-decode
-        $this->assertEquals(json_decode('true', true), true, 'true');
+        $this->assertEquals(true, json_decode('true', true), 'true');
 
-        $this->assertEquals(json_decode('"boolean"'), 'boolean');
+        $this->assertEquals('boolean', json_decode('"boolean"'));
     }
 
-    public function schema_examples_provider()
+    public function schema_examples_provider(): array
     {
         self::make_examples();
-        $ary = array();
+        $ary = [];
         foreach (self::$examples as $example) {
-            $ary[] = array($example);
+            $ary[] = [$example];
         }
         return $ary;
     }
 
-    protected static function make_examples()
+    protected static function make_examples(): void
     {
-        $primitive_examples = array_merge(array(
+        $primitive_examples = array_merge([
             new SchemaExample('"True"', false),
             new SchemaExample('{"no_type": "test"}', false),
             new SchemaExample('{"type": "panther"}', false)
-        ),
+        ],
             self::make_primitive_examples());
 
-        $array_examples = array(
+        $array_examples = [
             new SchemaExample('{"type": "array", "items": "long"}', true),
             new SchemaExample('
     {"type": "array",
      "items": {"type": "enum", "name": "Test", "symbols": ["A", "B"]}}
     ', true)
-        );
+        ];
 
-        $map_examples = array(
+        $map_examples = [
             new SchemaExample('{"type": "map", "values": "long"}', true),
             new SchemaExample('
     {"type": "map",
      "values": {"type": "enum", "name": "Test", "symbols": ["A", "B"]}}
     ', true)
-        );
+        ];
 
-        $union_examples = array(
+        $union_examples = [
             new SchemaExample('["string", "null", "long"]', true),
             new SchemaExample('["null", "null"]', false),
             new SchemaExample('["long", "long"]', false),
@@ -151,7 +152,7 @@ class SchemaTest extends TestCase
       {"type": "array", "items": "string"}]
     ', true,
                 
'[{"type":"record","name":"subtract","namespace":"com.example","fields":[{"name":"minuend","type":"int"},{"name":"subtrahend","type":"int"}]},{"type":"record","name":"divide","namespace":"com.example","fields":[{"name":"quotient","type":"int"},{"name":"dividend","type":"int"}]},{"type":"array","items":"string"}]'),
-        );
+        ];
 
         $fixed_examples = [
             new SchemaExample('{"type": "fixed", "name": "Test", "size": 1}', 
true),
@@ -447,7 +448,7 @@ class SchemaTest extends TestCase
 
     protected static function make_primitive_examples()
     {
-        $examples = array();
+        $examples = [];
         foreach ([
                      'null',
                      'boolean',
@@ -468,7 +469,7 @@ class SchemaTest extends TestCase
     /**
      * @dataProvider schema_examples_provider
      */
-    function test_parse($example)
+    function test_parse($example): void
     {
         $schema_string = $example->schema_string;
         try {
@@ -486,19 +487,19 @@ class SchemaTest extends TestCase
         }
     }
 
-    public function testToAvroIncludesAliases()
+    public function testToAvroIncludesAliases(): void
     {
         $hash = <<<SCHEMA
-{
-    "type": "record",
-    "name": "test_record",
-    "aliases": ["alt_record"],
-    "fields": [
-        { "name": "f", "type": { "type": "fixed", "size": 2, "name": 
"test_fixed", "aliases": ["alt_fixed"] } },
-        { "name": "e", "type": { "type": "enum", "symbols": ["A", "B"], 
"name": "test_enum", "aliases": ["alt_enum"] } }
-    ]
-}
-SCHEMA;
+                {
+                    "type": "record",
+                    "name": "test_record",
+                    "aliases": ["alt_record"],
+                    "fields": [
+                        { "name": "f", "type": { "type": "fixed", "size": 2, 
"name": "test_fixed", "aliases": ["alt_fixed"] } },
+                        { "name": "e", "type": { "type": "enum", "symbols": 
["A", "B"], "name": "test_enum", "aliases": ["alt_enum"] } }
+                    ]
+                }
+                SCHEMA;
         $schema = AvroSchema::parse($hash);
         $this->assertEquals($schema->toAvro(), json_decode($hash, true));
     }
@@ -584,20 +585,164 @@ SCHEMA);
     {
         $this->expectNotToPerformAssertions();
         AvroSchema::parse(<<<SCHEMA
-{
-    "type": "record",
-    "name": "fruits",
-    "fields": [
-        {
-            "name": "banana",
-            "type": "string",
-            "aliases": [
-                "yellow",
-                "yellow"
-            ]
-        }
-    ]
-}
-SCHEMA);
+            {
+                "type": "record",
+                "name": "fruits",
+                "fields": [
+                    {
+                        "name": "banana",
+                        "type": "string",
+                        "aliases": [
+                            "yellow",
+                            "yellow"
+                        ]
+                    }
+                ]
+            }
+            SCHEMA
+        );
+    }
+
+    public function testLogicalTypesInRecord(): void
+    {
+        $avro = <<<AVRO
+                {
+                "type": "record",
+                "name": "fruits",
+                "fields": [
+                    {
+                        "name": "UUID",
+                        "type": {
+                            "type": "string",
+                            "logicalType": "uuid"
+                        }
+                    },
+                    {
+                        "name": "decimal",
+                        "type": {
+                            "type": "bytes",
+                            "logicalType": "decimal",
+                            "precision": 6,
+                            "scale": 2
+                        }
+                    },
+                    {
+                        "name": "date",
+                        "type": {
+                            "type": "int",
+                            "logicalType": "date"
+                        }
+                    },
+                    {
+                        "name": "timeMillis",
+                        "type": {
+                            "type": "int",
+                            "logicalType": "time-millis"
+                        }
+                    },
+                    {
+                        "name": "timeMicros",
+                        "type": {
+                            "type": "long",
+                            "logicalType": "time-micros"
+                        }
+                    },
+                    {
+                        "name": "timestampMillis",
+                        "type": {
+                            "type": "long",
+                            "logicalType": "timestamp-millis"
+                        }
+                    },
+                    {
+                        "name": "timestampMicros",
+                        "type": {
+                            "type": "long",
+                            "logicalType": "timestamp-micros"
+                        }
+                    },
+                    {
+                        "name": "localTimestampMillis",
+                        "type": {
+                            "type": "long",
+                            "logicalType": "local-timestamp-millis"
+                        }
+                    },
+                    {
+                        "name": "localTimestampMicros",
+                        "type": {
+                            "type": "long",
+                            "logicalType": "local-timestamp-micros"
+                        }
+                    },
+                    {
+                        "name": "duration",
+                        "type": {
+                            "name": "inner_fixed",
+                            "type": "fixed",
+                            "size": 12,
+                            "logicalType": "duration"
+                        }
+                    }
+                ]
+            }
+            AVRO;
+
+
+        $schema = AvroSchema::parse($avro);
+
+        self::assertEquals($schema->toAvro(), json_decode($avro, true));
+    }
+
+    public static function invalidDecimalLogicalTypeDataProvider(): array
+    {
+        return [
+            'bytes - invalid precision' => [
+                '{"type": "bytes", "logicalType": "decimal", "precision": -1, 
"scale": 2}',
+                new AvroException("Precision '-1' is invalid. It must be a 
positive integer."),
+            ],
+            'bytes - invalid value for precision (float)' => [
+                '{"type": "bytes", "logicalType": "decimal", "precision": 
11.23, "scale": 2}',
+                new AvroException("Invalid value '11.23' for 'precision' 
attribute of decimal logical type."),
+            ],
+            'bytes - invalid value for precision (string)' => [
+                '{"type": "bytes", "logicalType": "decimal", "precision": 
"banana", "scale": 2}',
+                new AvroException("Invalid value 'banana' for 'precision' 
attribute of decimal logical type."),
+            ],
+            'bytes - invalid scale' => [
+                '{"type": "bytes", "logicalType": "decimal", "precision": 2, 
"scale": -1}',
+                new AvroException("Scale '-1' is invalid. It must be a 
non-negative integer."),
+            ],
+            'bytes - invalid scale for precision' => [
+                '{"type": "bytes", "logicalType": "decimal", "precision": 2, 
"scale": 2}',
+                new AvroException("Scale must be a lower than precision 
(scale='2', precision='2')."),
+            ],
+            'bytes - invalid value for scale (float)' => [
+                '{"type": "bytes", "logicalType": "decimal", "precision": 2, 
"scale": 9.12}',
+                new AvroException("Invalid value '9.12' for 'scale' attribute 
of decimal logical type."),
+            ],
+            'bytes - invalid value for scale (string)' => [
+                '{"type": "bytes", "logicalType": "decimal", "precision": 2, 
"scale": "two"}',
+                new AvroException("Invalid value 'two' for 'scale' attribute 
of decimal logical type."),
+            ],
+            'fixed - invalid precision' => [
+                '{"name": "fixed_decimal", "type": "fixed", "logicalType": 
"decimal", "size": 2, "precision": -1, "scale": 2}',
+                new AvroException("Precision '-1' is invalid. It must be a 
positive integer."),
+            ],
+            'fixed - invalid value for precision with specified size' => [
+                '{"name": "fixed_decimal", "type": "fixed", "logicalType": 
"decimal", "size": 2, "precision": 6, "scale": 2}',
+                new AvroException("Invalid precision for specified fixed size 
(size='2', precision='6')."),
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider invalidDecimalLogicalTypeDataProvider
+     */
+    public function testDecimalLogicalTypeWithInvalidParameters(string 
$schema, AvroException $exception): void
+    {
+        $this->expectException(get_class($exception));
+        $this->expectExceptionMessage($exception->getMessage());
+        AvroSchema::parse($schema);
     }
 }


Reply via email to