martin-g commented on code in PR #3708:
URL: https://github.com/apache/avro/pull/3708#discussion_r3457669888


##########
lang/php/lib/Generator/AvroCodeGenerator.php:
##########
@@ -0,0 +1,366 @@
+<?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\Generator;
+
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroNamedSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+use PhpParser\BuilderFactory;
+use PhpParser\Node;
+use PhpParser\Node\Scalar\String_;
+use PhpParser\Node\Stmt;
+use PhpParser\PrettyPrinter\Standard;
+
+class AvroCodeGenerator
+{
+    private BuilderFactory $factory;
+    private Standard $printer;
+
+    /** @var array<string, AvroSchema> */
+    private array $registry = [];
+
+    public function __construct()
+    {
+        $this->factory = new BuilderFactory();
+        $this->printer = new Standard(['shortArraySyntax' => true]);
+    }
+
+    /**
+     * @return array<string, string> Map of filename to file contents
+     */
+    public function translate(
+        AvroSchema $schema,
+        string $path,
+        string $phpNamespace
+    ): array {
+        $this->buildRegistry($schema);
+
+        $files = [];
+
+        foreach ($this->registry as $registeredSchema) {
+            $node = match (true) {
+                $registeredSchema instanceof AvroEnumSchema => 
$this->buildEnum(
+                    $registeredSchema,
+                    $phpNamespace,
+                    $registeredSchema->symbols()
+                ),
+                $registeredSchema instanceof AvroRecordSchema => 
$this->buildRecord(
+                    $registeredSchema,
+                    $phpNamespace
+                ),
+                default => null
+            };
+
+            if (null !== $node && $registeredSchema instanceof 
AvroNamedSchema) {
+                $filename = 
$path.'/'.ucwords($registeredSchema->name()).'.php';

Review Comment:
   This should probably use the schema fullname, i.e. namespace + name. 
Currently it will overwrite the file if there are two schemas with the same 
name in different namespaces.



##########
lang/php/bin/avro:
##########
@@ -0,0 +1,52 @@
+#!/usr/bin/env php
+<?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.
+ */
+

Review Comment:
   Please add some documentation about the purpose of this file.



##########
lang/php/lib/Generator/AvroCodeGenerator.php:
##########
@@ -0,0 +1,366 @@
+<?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\Generator;
+
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroNamedSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+use PhpParser\BuilderFactory;
+use PhpParser\Node;
+use PhpParser\Node\Scalar\String_;
+use PhpParser\Node\Stmt;
+use PhpParser\PrettyPrinter\Standard;
+
+class AvroCodeGenerator
+{
+    private BuilderFactory $factory;
+    private Standard $printer;
+
+    /** @var array<string, AvroSchema> */
+    private array $registry = [];
+
+    public function __construct()
+    {
+        $this->factory = new BuilderFactory();
+        $this->printer = new Standard(['shortArraySyntax' => true]);
+    }
+
+    /**
+     * @return array<string, string> Map of filename to file contents
+     */
+    public function translate(
+        AvroSchema $schema,
+        string $path,
+        string $phpNamespace
+    ): array {
+        $this->buildRegistry($schema);
+
+        $files = [];
+
+        foreach ($this->registry as $registeredSchema) {
+            $node = match (true) {
+                $registeredSchema instanceof AvroEnumSchema => 
$this->buildEnum(
+                    $registeredSchema,
+                    $phpNamespace,
+                    $registeredSchema->symbols()
+                ),
+                $registeredSchema instanceof AvroRecordSchema => 
$this->buildRecord(
+                    $registeredSchema,
+                    $phpNamespace
+                ),
+                default => null
+            };
+
+            if (null !== $node && $registeredSchema instanceof 
AvroNamedSchema) {
+                $filename = 
$path.'/'.ucwords($registeredSchema->name()).'.php';
+                $files[$filename] = 
"<?php\n\ndeclare(strict_types=1);\n\n{$this->printer->prettyPrint([$node])}\n";
+            }
+        }
+
+        return $files;
+    }
+
+    private function buildRegistry(AvroSchema $rootSchema): void
+    {
+        $this->registry = [];
+        $this->collectSchemas($rootSchema);
+    }
+
+    private function collectSchemas(AvroSchema $schema): void
+    {
+        switch ($schema::class) {
+            case AvroRecordSchema::class:
+                if (!array_key_exists($schema->fullname(), $this->registry)) {
+                    $this->registry[$schema->fullname()] = $schema;
+                    foreach ($schema->fields() as $field) {
+                        $this->collectSchemas($field->type());
+                    }
+                }
+
+                break;
+            case AvroEnumSchema::class:
+                $this->registry[$schema->fullname()] = $schema;
+
+                break;
+            case AvroArraySchema::class:
+                $this->collectSchemas($schema->items());
+
+                break;
+            case AvroMapSchema::class:
+                $this->collectSchemas($schema->values());
+
+                break;
+            case AvroUnionSchema::class:
+                foreach ($schema->schemas() as $unionSchema) {
+                    $this->collectSchemas($unionSchema);
+                }
+
+                break;
+        }
+    }
+
+    private function buildRecord(
+        AvroRecordSchema $avroRecord,
+        string $phpNamespace
+    ): Node {
+        $className = ucwords($avroRecord->name());
+        $class = 
$this->factory->class($className)->makeFinal()->implement('\\JsonSerializable');
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $property = $this->factory->property($field->name())
+                ->makePrivate()
+                ->setType($phpType);
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $property->setDocComment('/** @var '.$phpDocType.' */');
+            }
+
+            if ($field->hasDefaultValue()) {
+                
$property->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $class->addStmt($property);
+        }
+
+        $constructor = $this->factory->method('__construct')->makePublic();
+        $constructorParamDocs = [];
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $param = $this->factory->param($field->name())->setType($phpType);
+            if ($field->hasDefaultValue()) {
+                
$param->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $constructorParamDocs[] = '@param '.$phpDocType.' 
$'.$field->name();
+            }
+
+            $constructor->addParam($param);
+            $constructor->addStmt(
+                new Node\Expr\Assign(
+                    new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $field->name()),
+                    new Node\Expr\Variable($field->name())
+                )
+            );
+        }
+        if ([] !== $constructorParamDocs) {
+            $docLines = "/**\n";
+            foreach ($constructorParamDocs as $doc) {
+                $docLines .= ' * '.$doc."\n";
+            }
+            $docLines .= ' */';
+            $constructor->setDocComment($docLines);
+        }
+        $class->addStmt($constructor);
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $getter = $this->factory->method($field->name())
+                ->makePublic()
+                ->setReturnType($phpType)
+                ->addStmt(
+                    new Stmt\Return_(
+                        new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $field->name())
+                    )
+                );
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $getter->setDocComment('/** @return '.$phpDocType.' */');
+            }
+
+            $class->addStmt($getter);
+        }
+
+        $arrayItems = [];
+        foreach ($avroRecord->fields() as $field) {
+            $arrayItems[] = new Node\ArrayItem(
+                $this->buildJsonSerializeValue($field->type(), $field->name()),
+                new String_($field->name())
+            );
+        }
+        $jsonSerialize = $this->factory->method('jsonSerialize')
+            ->makePublic()
+            ->setReturnType('mixed')
+            ->addStmt(
+                new Stmt\Return_(
+                    new Node\Expr\Array_($arrayItems, ['kind' => 
Node\Expr\Array_::KIND_SHORT])
+                )
+            );
+        $class->addStmt($jsonSerialize);
+
+        return $this->factory->namespace($phpNamespace)
+            ->addStmt($class)
+            ->getNode();
+    }
+
+    /**
+     * Builds the expression used inside jsonSerialize() for a single field.
+     *
+     * - EnumSchema        → $this->field->value       (plain string for Avro 
+ JSON)
+     * - union[null, Enum] → $this->field?->value      (null-safe, still plain)
+     * - anything else     → $this->field
+     */
+    private function buildJsonSerializeValue(AvroSchema $fieldType, string 
$fieldName): Node\Expr
+    {
+        $propertyFetch = new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $fieldName);
+
+        if ($fieldType instanceof AvroEnumSchema) {
+            return new Node\Expr\PropertyFetch($propertyFetch, 'value');
+        }
+
+        if ($fieldType instanceof AvroUnionSchema) {
+            $nonNullSchemas = array_values(array_filter(
+                $fieldType->schemas(),
+                static fn (AvroSchema $s): bool => !($s instanceof 
AvroPrimitiveSchema && AvroSchema::NULL_TYPE === $s->type())
+            ));
+
+            if (1 === count($nonNullSchemas) && $nonNullSchemas[0] instanceof 
AvroEnumSchema) {
+                return new Node\Expr\NullsafePropertyFetch($propertyFetch, 
'value');
+            }
+        }
+
+        return $propertyFetch;
+    }
+
+    /**
+     * @param list<string> $values
+     */
+    private function buildEnum(
+        AvroEnumSchema $avroEnum,
+        string $phpNamespace,
+        array $values
+    ): Node {
+        $className = ucwords($avroEnum->name());

Review Comment:
   Same issue here - should the fullname be used ?



##########
lang/php/lib/Console/GenerateCommand.php:
##########
@@ -0,0 +1,149 @@
+<?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\Console;
+
+use Apache\Avro\Generator\AvroCodeGenerator;
+use Apache\Avro\Schema\AvroSchema;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+#[AsCommand(
+    name: 'generate',
+    description: 'Generate PHP classes from Avro schema files',
+)]
+class GenerateCommand extends Command
+{
+    protected function configure(): void
+    {
+        $this
+            ->addOption('file', 'f', InputOption::VALUE_OPTIONAL, 'One Avro 
schema file (.avsc)')
+            ->addOption('directory', 'd', InputOption::VALUE_OPTIONAL, 'A 
directory containing multiple Avro schema files (.avsc)')
+            ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output 
directory for generated PHP files')
+            ->addOption('namespace', 'ns', InputOption::VALUE_REQUIRED, 'PHP 
namespace for generated classes');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface 
$output): int
+    {
+        $io = new SymfonyStyle($input, $output);
+
+        /** @var string $outputDir */
+        $outputDir = $input->getOption('output');
+        /** @var string $namespace */
+        $namespace = $input->getOption('namespace');
+
+        /** @var null|string $file */
+        $file = $input->getOption('file');
+        /** @var null|string $directory */
+        $directory = $input->getOption('directory');
+
+        if (
+            (null === $file && null === $directory)
+            || (null !== $file && null !== $directory)
+        ) {
+            $io->error('You must provide a file path or a directory');
+
+            return Command::FAILURE;
+        }
+
+        if (null === $outputDir || '' === $outputDir) {
+            $io->error('Output directory is required (--output / -o).');
+
+            return Command::FAILURE;
+        }
+
+        if (null === $namespace || '' === $namespace) {
+            $io->error('PHP namespace is required (--namespace / -ns).');
+
+            return Command::FAILURE;
+        }
+
+        if (!is_dir($outputDir) && !mkdir($outputDir, 0755, true) && 
!is_dir($outputDir)) {
+            $io->error(sprintf('Could not create output directory "%s".', 
$outputDir));
+
+            return Command::FAILURE;
+        }
+
+        $outputDir = rtrim((string) realpath($outputDir), '/');

Review Comment:
   `realpath()` may return `false` on failure (e.g. permissions issue) and the 
cast to string will make `$outputDir = ""`, i.e. the root folder.



##########
lang/php/lib/Datum/AvroSpecificDatumWriter.php:
##########
@@ -0,0 +1,244 @@
+<?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;
+
+use Apache\Avro\AvroException;
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+
+/**
+ * Writes data from code-generated (specific) record classes to the encoder.
+ *
+ * It knows how to extract field values from generated PHP class instances 
(via getter methods) and backed enum
+ * instances, encoding them according to the Avro schema.
+ *
+ * Generated records expose each field through a getter method whose name 
matches the Avro field name.
+ * Generated enums are PHP 8.1+ backed string enums whose ->value holds the 
Avro symbol string.
+ *
+ * Usage:
+ *     $schema  = AvroSchema::parse($json);
+ *     $writer  = new AvroSpecificDatumWriter($schema);
+ *     $io      = new \Apache\Avro\IO\AvroStringIO();
+ *     $encoder = new AvroIOBinaryEncoder($io);
+ *     $writer->write($myGeneratedObject, $encoder);
+ *     $bytes   = $io->string();
+ */
+class AvroSpecificDatumWriter
+{
+    public function __construct(
+        private readonly AvroSchema $writersSchema
+    ) {
+    }
+
+    /**
+     * Serializes the given datum (a generated record instance) to the encoder.
+     *
+     * @throws AvroException
+     */
+    public function write(object $datum, AvroIOBinaryEncoder $encoder): void
+    {
+        $this->writeData($this->writersSchema, $datum, $encoder);
+    }
+
+    /**
+     * @throws AvroException
+     */
+    private function writeData(AvroSchema $schema, mixed $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        match (true) {
+            $schema instanceof AvroRecordSchema => $this->writeRecord($schema, 
$datum, $encoder),
+            $schema instanceof AvroEnumSchema => $this->writeEnum($schema, 
$datum, $encoder),
+            $schema instanceof AvroArraySchema => $this->writeArray($schema, 
$datum, $encoder),
+            $schema instanceof AvroMapSchema => $this->writeMap($schema, 
$datum, $encoder),
+            $schema instanceof AvroUnionSchema => $this->writeUnion($schema, 
$datum, $encoder),
+            $schema instanceof AvroPrimitiveSchema => 
$this->writePrimitive($schema, $datum, $encoder),
+            default => throw new AvroException(sprintf('Unsupported schema 
type: %s', $schema->type())),
+        };
+    }
+
+    /**
+     * Writes a record by calling the getter for each field defined in the 
schema.
+     *
+     * @throws AvroException
+     */
+    private function writeRecord(AvroRecordSchema $schema, object $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        foreach ($schema->fields() as $field) {
+            $getter = $field->name();
+            if (!method_exists($datum, $getter)) {
+                throw new AvroIOTypeException($schema, $datum);
+            }
+            $value = $datum->{$getter}();
+            $this->writeData($field->type(), $value, $encoder);
+        }
+    }
+
+    /**
+     * Writes a backed enum value by looking up its symbol index.
+     *
+     * @throws AvroException
+     */
+    private function writeEnum(AvroEnumSchema $schema, mixed $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        if (!$datum instanceof \BackedEnum) {
+            throw new AvroIOTypeException($schema, $datum);
+        }
+
+        $symbolIndex = $schema->symbolIndex($datum->value);
+        $encoder->writeInt($symbolIndex);
+    }
+
+    /**
+     * @param list<mixed> $datum
+     *
+     * @throws AvroException
+     */
+    private function writeArray(AvroArraySchema $schema, array $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        $count = count($datum);
+        if ($count > 0) {
+            $encoder->writeLong($count);
+            foreach ($datum as $item) {
+                $this->writeData($schema->items(), $item, $encoder);
+            }
+        }
+        $encoder->writeLong(0);
+    }
+
+    /**
+     * @param array<string, mixed> $datum
+     *
+     * @throws AvroException
+     */
+    private function writeMap(AvroMapSchema $schema, array $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        $count = count($datum);
+        if ($count > 0) {
+            $encoder->writeLong($count);
+            foreach ($datum as $key => $value) {
+                $encoder->writeString((string) $key);
+                $this->writeData($schema->values(), $value, $encoder);
+            }
+        }
+        $encoder->writeLong(0);
+    }
+
+    /**
+     * Writes a union value by finding the matching branch schema.
+     *
+     * @throws AvroIOTypeException if no branch matches the datum
+     * @throws AvroException
+     */
+    private function writeUnion(AvroUnionSchema $schema, mixed $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        $matchedIndex = null;
+        $matchedSchema = null;
+
+        foreach ($schema->schemas() as $index => $branchSchema) {
+            if ($this->datumMatchesSchema($branchSchema, $datum)) {
+                $matchedIndex = $index;
+                $matchedSchema = $branchSchema;
+
+                break;
+            }
+        }
+
+        if (null === $matchedSchema) {
+            throw new AvroIOTypeException($schema, $datum);
+        }
+
+        $encoder->writeLong($matchedIndex);
+        $this->writeData($matchedSchema, $datum, $encoder);
+    }
+
+    /**
+     * Writes a primitive value using the appropriate encoder method.
+     *
+     * @throws AvroException
+     */
+    private function writePrimitive(AvroPrimitiveSchema $schema, mixed $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        match ($schema->type()) {
+            AvroSchema::NULL_TYPE => $encoder->writeNull($datum),
+            AvroSchema::BOOLEAN_TYPE => $encoder->writeBoolean($datum),
+            AvroSchema::INT_TYPE => $encoder->writeInt($datum),
+            AvroSchema::LONG_TYPE => $encoder->writeLong($datum),
+            AvroSchema::FLOAT_TYPE => $encoder->writeFloat($datum),
+            AvroSchema::DOUBLE_TYPE => $encoder->writeDouble($datum),
+            AvroSchema::STRING_TYPE => $encoder->writeString($datum),
+            AvroSchema::BYTES_TYPE => $encoder->writeBytes($datum),
+            default => throw new AvroException(sprintf('Unknown primitive 
type: %s', $schema->type())),
+        };
+    }
+
+    /**
+     * Determines whether the given datum matches the given schema branch.
+     * Used by writeUnion() to find the correct branch index.
+     */
+    private function datumMatchesSchema(AvroSchema $schema, mixed $datum): bool
+    {
+        return match (true) {
+            $schema instanceof AvroPrimitiveSchema => 
$this->datumMatchesPrimitive($schema, $datum),
+            $schema instanceof AvroEnumSchema => $datum instanceof \BackedEnum
+                && $this->classNameMatchesSchema($datum, $schema->name()),
+            $schema instanceof AvroRecordSchema => is_object($datum)
+                && !($datum instanceof \BackedEnum)
+                && $this->classNameMatchesSchema($datum, $schema->name()),
+            $schema instanceof AvroArraySchema => is_array($datum)
+                && ([] === $datum || array_is_list($datum)),
+            $schema instanceof AvroMapSchema => is_array($datum),
+            default => false,
+        };
+    }
+
+    private function datumMatchesPrimitive(AvroPrimitiveSchema $schema, mixed 
$datum): bool
+    {
+        return match ($schema->type()) {
+            AvroSchema::NULL_TYPE => null === $datum,
+            AvroSchema::BOOLEAN_TYPE => is_bool($datum),
+            AvroSchema::INT_TYPE => is_int($datum)
+                && $datum >= AvroSchema::INT_MIN_VALUE
+                && $datum <= AvroSchema::INT_MAX_VALUE,
+            AvroSchema::LONG_TYPE => is_int($datum),
+            AvroSchema::FLOAT_TYPE, AvroSchema::DOUBLE_TYPE => 
is_float($datum) || is_int($datum),
+            AvroSchema::STRING_TYPE, AvroSchema::BYTES_TYPE => 
is_string($datum),
+            default => false,
+        };
+    }
+
+    /**
+     * Checks whether the short class name of the datum matches the Avro 
schema name.
+     * Generated classes use ucwords(schemaName) as the class name.
+     */
+    private function classNameMatchesSchema(object $datum, string 
$schemaName): bool
+    {
+        $className = (new \ReflectionClass($datum))->getShortName();

Review Comment:
   Would the following be faster:
   
   ```suggestion
           $class = get_class($datum);
           $className = false !== ($pos = strrpos($class, '\\')) ? 
substr($class, $pos + 1) : $class;
   ```
   ?



##########
lang/php/lib/Schema/AvroEnumSchema.php:
##########
@@ -72,13 +72,14 @@ public function symbols()
      * @return bool true if the given symbol exists in this
      *          enum schema and false otherwise
      */
-    public function hasSymbol($symbol)
+    public function hasSymbol($symbol): bool
     {
         return in_array($symbol, $this->symbols);

Review Comment:
   ```suggestion
           return is_string($symbol) && in_array($symbol, $this->symbols, true);
   ```
   a bit more strict check



##########
lang/php/lib/Generator/AvroCodeGenerator.php:
##########
@@ -0,0 +1,366 @@
+<?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\Generator;
+
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroNamedSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+use PhpParser\BuilderFactory;
+use PhpParser\Node;
+use PhpParser\Node\Scalar\String_;
+use PhpParser\Node\Stmt;
+use PhpParser\PrettyPrinter\Standard;
+
+class AvroCodeGenerator
+{
+    private BuilderFactory $factory;
+    private Standard $printer;
+
+    /** @var array<string, AvroSchema> */
+    private array $registry = [];
+
+    public function __construct()
+    {
+        $this->factory = new BuilderFactory();
+        $this->printer = new Standard(['shortArraySyntax' => true]);
+    }
+
+    /**
+     * @return array<string, string> Map of filename to file contents
+     */
+    public function translate(
+        AvroSchema $schema,
+        string $path,
+        string $phpNamespace
+    ): array {
+        $this->buildRegistry($schema);
+
+        $files = [];
+
+        foreach ($this->registry as $registeredSchema) {
+            $node = match (true) {
+                $registeredSchema instanceof AvroEnumSchema => 
$this->buildEnum(
+                    $registeredSchema,
+                    $phpNamespace,
+                    $registeredSchema->symbols()
+                ),
+                $registeredSchema instanceof AvroRecordSchema => 
$this->buildRecord(
+                    $registeredSchema,
+                    $phpNamespace
+                ),
+                default => null
+            };
+
+            if (null !== $node && $registeredSchema instanceof 
AvroNamedSchema) {
+                $filename = 
$path.'/'.ucwords($registeredSchema->name()).'.php';
+                $files[$filename] = 
"<?php\n\ndeclare(strict_types=1);\n\n{$this->printer->prettyPrint([$node])}\n";
+            }
+        }
+
+        return $files;
+    }
+
+    private function buildRegistry(AvroSchema $rootSchema): void
+    {
+        $this->registry = [];
+        $this->collectSchemas($rootSchema);
+    }
+
+    private function collectSchemas(AvroSchema $schema): void
+    {
+        switch ($schema::class) {

Review Comment:
   Elsewhere you use `match (true) { $schema instanceof Foo => ...}`. 
   Why this is different ?



##########
lang/php/lib/Datum/AvroSpecificDatumWriter.php:
##########
@@ -0,0 +1,244 @@
+<?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;
+
+use Apache\Avro\AvroException;
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+
+/**
+ * Writes data from code-generated (specific) record classes to the encoder.
+ *
+ * It knows how to extract field values from generated PHP class instances 
(via getter methods) and backed enum
+ * instances, encoding them according to the Avro schema.
+ *
+ * Generated records expose each field through a getter method whose name 
matches the Avro field name.
+ * Generated enums are PHP 8.1+ backed string enums whose ->value holds the 
Avro symbol string.
+ *
+ * Usage:
+ *     $schema  = AvroSchema::parse($json);
+ *     $writer  = new AvroSpecificDatumWriter($schema);
+ *     $io      = new \Apache\Avro\IO\AvroStringIO();
+ *     $encoder = new AvroIOBinaryEncoder($io);
+ *     $writer->write($myGeneratedObject, $encoder);
+ *     $bytes   = $io->string();
+ */
+class AvroSpecificDatumWriter
+{
+    public function __construct(
+        private readonly AvroSchema $writersSchema
+    ) {
+    }
+
+    /**
+     * Serializes the given datum (a generated record instance) to the encoder.
+     *
+     * @throws AvroException
+     */
+    public function write(object $datum, AvroIOBinaryEncoder $encoder): void
+    {
+        $this->writeData($this->writersSchema, $datum, $encoder);
+    }
+
+    /**
+     * @throws AvroException
+     */
+    private function writeData(AvroSchema $schema, mixed $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        match (true) {
+            $schema instanceof AvroRecordSchema => $this->writeRecord($schema, 
$datum, $encoder),
+            $schema instanceof AvroEnumSchema => $this->writeEnum($schema, 
$datum, $encoder),
+            $schema instanceof AvroArraySchema => $this->writeArray($schema, 
$datum, $encoder),
+            $schema instanceof AvroMapSchema => $this->writeMap($schema, 
$datum, $encoder),
+            $schema instanceof AvroUnionSchema => $this->writeUnion($schema, 
$datum, $encoder),
+            $schema instanceof AvroPrimitiveSchema => 
$this->writePrimitive($schema, $datum, $encoder),
+            default => throw new AvroException(sprintf('Unsupported schema 
type: %s', $schema->type())),
+        };
+    }
+
+    /**
+     * Writes a record by calling the getter for each field defined in the 
schema.
+     *
+     * @throws AvroException
+     */
+    private function writeRecord(AvroRecordSchema $schema, object $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        foreach ($schema->fields() as $field) {
+            $getter = $field->name();
+            if (!method_exists($datum, $getter)) {
+                throw new AvroIOTypeException($schema, $datum);
+            }
+            $value = $datum->{$getter}();
+            $this->writeData($field->type(), $value, $encoder);
+        }
+    }
+
+    /**
+     * Writes a backed enum value by looking up its symbol index.
+     *
+     * @throws AvroException
+     */
+    private function writeEnum(AvroEnumSchema $schema, mixed $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        if (!$datum instanceof \BackedEnum) {
+            throw new AvroIOTypeException($schema, $datum);
+        }
+
+        $symbolIndex = $schema->symbolIndex($datum->value);
+        $encoder->writeInt($symbolIndex);
+    }
+
+    /**
+     * @param list<mixed> $datum
+     *
+     * @throws AvroException
+     */
+    private function writeArray(AvroArraySchema $schema, array $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        $count = count($datum);
+        if ($count > 0) {
+            $encoder->writeLong($count);
+            foreach ($datum as $item) {
+                $this->writeData($schema->items(), $item, $encoder);
+            }
+        }
+        $encoder->writeLong(0);
+    }
+
+    /**
+     * @param array<string, mixed> $datum
+     *
+     * @throws AvroException
+     */
+    private function writeMap(AvroMapSchema $schema, array $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        $count = count($datum);
+        if ($count > 0) {
+            $encoder->writeLong($count);
+            foreach ($datum as $key => $value) {
+                $encoder->writeString((string) $key);
+                $this->writeData($schema->values(), $value, $encoder);
+            }
+        }
+        $encoder->writeLong(0);
+    }
+
+    /**
+     * Writes a union value by finding the matching branch schema.
+     *
+     * @throws AvroIOTypeException if no branch matches the datum
+     * @throws AvroException
+     */
+    private function writeUnion(AvroUnionSchema $schema, mixed $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        $matchedIndex = null;
+        $matchedSchema = null;
+
+        foreach ($schema->schemas() as $index => $branchSchema) {
+            if ($this->datumMatchesSchema($branchSchema, $datum)) {
+                $matchedIndex = $index;
+                $matchedSchema = $branchSchema;
+
+                break;
+            }
+        }
+
+        if (null === $matchedSchema) {
+            throw new AvroIOTypeException($schema, $datum);
+        }
+
+        $encoder->writeLong($matchedIndex);
+        $this->writeData($matchedSchema, $datum, $encoder);
+    }
+
+    /**
+     * Writes a primitive value using the appropriate encoder method.
+     *
+     * @throws AvroException
+     */
+    private function writePrimitive(AvroPrimitiveSchema $schema, mixed $datum, 
AvroIOBinaryEncoder $encoder): void
+    {
+        match ($schema->type()) {
+            AvroSchema::NULL_TYPE => $encoder->writeNull($datum),
+            AvroSchema::BOOLEAN_TYPE => $encoder->writeBoolean($datum),
+            AvroSchema::INT_TYPE => $encoder->writeInt($datum),
+            AvroSchema::LONG_TYPE => $encoder->writeLong($datum),
+            AvroSchema::FLOAT_TYPE => $encoder->writeFloat($datum),
+            AvroSchema::DOUBLE_TYPE => $encoder->writeDouble($datum),
+            AvroSchema::STRING_TYPE => $encoder->writeString($datum),
+            AvroSchema::BYTES_TYPE => $encoder->writeBytes($datum),
+            default => throw new AvroException(sprintf('Unknown primitive 
type: %s', $schema->type())),
+        };
+    }
+
+    /**
+     * Determines whether the given datum matches the given schema branch.
+     * Used by writeUnion() to find the correct branch index.
+     */
+    private function datumMatchesSchema(AvroSchema $schema, mixed $datum): bool
+    {
+        return match (true) {
+            $schema instanceof AvroPrimitiveSchema => 
$this->datumMatchesPrimitive($schema, $datum),
+            $schema instanceof AvroEnumSchema => $datum instanceof \BackedEnum
+                && $this->classNameMatchesSchema($datum, $schema->name()),
+            $schema instanceof AvroRecordSchema => is_object($datum)
+                && !($datum instanceof \BackedEnum)
+                && $this->classNameMatchesSchema($datum, $schema->name()),
+            $schema instanceof AvroArraySchema => is_array($datum)
+                && ([] === $datum || array_is_list($datum)),
+            $schema instanceof AvroMapSchema => is_array($datum),
+            default => false,
+        };
+    }
+
+    private function datumMatchesPrimitive(AvroPrimitiveSchema $schema, mixed 
$datum): bool
+    {
+        return match ($schema->type()) {
+            AvroSchema::NULL_TYPE => null === $datum,
+            AvroSchema::BOOLEAN_TYPE => is_bool($datum),
+            AvroSchema::INT_TYPE => is_int($datum)
+                && $datum >= AvroSchema::INT_MIN_VALUE
+                && $datum <= AvroSchema::INT_MAX_VALUE,
+            AvroSchema::LONG_TYPE => is_int($datum),
+            AvroSchema::FLOAT_TYPE, AvroSchema::DOUBLE_TYPE => 
is_float($datum) || is_int($datum),
+            AvroSchema::STRING_TYPE, AvroSchema::BYTES_TYPE => 
is_string($datum),
+            default => false,
+        };
+    }
+
+    /**
+     * Checks whether the short class name of the datum matches the Avro 
schema name.
+     * Generated classes use ucwords(schemaName) as the class name.
+     */
+    private function classNameMatchesSchema(object $datum, string 
$schemaName): bool
+    {
+        $className = (new \ReflectionClass($datum))->getShortName();

Review Comment:
   A follow-up: Should the namespaces be used to in the comparison ?
   `com.a.User` and `com.b.User` now both match Avro schema `User`.



##########
lang/php/lib/Generator/AvroCodeGenerator.php:
##########
@@ -0,0 +1,366 @@
+<?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\Generator;
+
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroNamedSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+use PhpParser\BuilderFactory;
+use PhpParser\Node;
+use PhpParser\Node\Scalar\String_;
+use PhpParser\Node\Stmt;
+use PhpParser\PrettyPrinter\Standard;
+
+class AvroCodeGenerator
+{
+    private BuilderFactory $factory;
+    private Standard $printer;
+
+    /** @var array<string, AvroSchema> */
+    private array $registry = [];
+
+    public function __construct()
+    {
+        $this->factory = new BuilderFactory();
+        $this->printer = new Standard(['shortArraySyntax' => true]);
+    }
+
+    /**
+     * @return array<string, string> Map of filename to file contents
+     */
+    public function translate(
+        AvroSchema $schema,
+        string $path,
+        string $phpNamespace
+    ): array {
+        $this->buildRegistry($schema);
+
+        $files = [];
+
+        foreach ($this->registry as $registeredSchema) {
+            $node = match (true) {
+                $registeredSchema instanceof AvroEnumSchema => 
$this->buildEnum(
+                    $registeredSchema,
+                    $phpNamespace,
+                    $registeredSchema->symbols()
+                ),
+                $registeredSchema instanceof AvroRecordSchema => 
$this->buildRecord(
+                    $registeredSchema,
+                    $phpNamespace
+                ),
+                default => null
+            };
+
+            if (null !== $node && $registeredSchema instanceof 
AvroNamedSchema) {
+                $filename = 
$path.'/'.ucwords($registeredSchema->name()).'.php';
+                $files[$filename] = 
"<?php\n\ndeclare(strict_types=1);\n\n{$this->printer->prettyPrint([$node])}\n";
+            }
+        }
+
+        return $files;
+    }
+
+    private function buildRegistry(AvroSchema $rootSchema): void
+    {
+        $this->registry = [];
+        $this->collectSchemas($rootSchema);
+    }
+
+    private function collectSchemas(AvroSchema $schema): void
+    {
+        switch ($schema::class) {
+            case AvroRecordSchema::class:
+                if (!array_key_exists($schema->fullname(), $this->registry)) {
+                    $this->registry[$schema->fullname()] = $schema;
+                    foreach ($schema->fields() as $field) {
+                        $this->collectSchemas($field->type());
+                    }
+                }
+
+                break;
+            case AvroEnumSchema::class:
+                $this->registry[$schema->fullname()] = $schema;
+
+                break;
+            case AvroArraySchema::class:
+                $this->collectSchemas($schema->items());
+
+                break;
+            case AvroMapSchema::class:
+                $this->collectSchemas($schema->values());
+
+                break;
+            case AvroUnionSchema::class:
+                foreach ($schema->schemas() as $unionSchema) {
+                    $this->collectSchemas($unionSchema);
+                }
+
+                break;
+        }
+    }
+
+    private function buildRecord(
+        AvroRecordSchema $avroRecord,
+        string $phpNamespace
+    ): Node {
+        $className = ucwords($avroRecord->name());
+        $class = 
$this->factory->class($className)->makeFinal()->implement('\\JsonSerializable');
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $property = $this->factory->property($field->name())
+                ->makePrivate()
+                ->setType($phpType);
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $property->setDocComment('/** @var '.$phpDocType.' */');
+            }
+
+            if ($field->hasDefaultValue()) {
+                
$property->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $class->addStmt($property);
+        }
+
+        $constructor = $this->factory->method('__construct')->makePublic();
+        $constructorParamDocs = [];
+        foreach ($avroRecord->fields() as $field) {

Review Comment:
   Should the fields be sorted so that the ones with default values to be last 
in the constructor arguments ?



##########
lang/php/bin/avro:
##########
@@ -0,0 +1,52 @@
+#!/usr/bin/env php
+<?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);
+
+const _AVRO_AUTOLOADER_LOCATIONS = [
+        __DIR__ . '/../../autoload.php',
+        __DIR__ . '/../vendor/autoload.php',
+        __DIR__ . '/../../../vendor/autoload.php',
+        __DIR__ . '/vendor/autoload.php'
+];
+
+foreach (_AVRO_AUTOLOADER_LOCATIONS as $autoloader) {
+    if (file_exists($autoloader)) {
+        require $autoloader;
+        break;
+    }
+}
+
+if (!class_exists(\Apache\Avro\Console\GenerateCommand::class)) {
+    fwrite(STDERR, "Error: Composer autoloader not found. Run 'composer 
install' first.\n");
+    exit(1);
+}
+
+use Apache\Avro\Console\GenerateCommand;
+use Composer\InstalledVersions;
+use Symfony\Component\Console\Application;
+
+$version = InstalledVersions::getPrettyVersion('apache/avro');

Review Comment:
   ```suggestion
   try {
       $version = InstalledVersions::getPrettyVersion('apache/avro') ?? 
'unknown';
   } catch (\Throwable) {
       $version = 'unknown';
   }
   ```
   



##########
lang/php/bin/avro:
##########
@@ -0,0 +1,52 @@
+#!/usr/bin/env php
+<?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);
+
+const _AVRO_AUTOLOADER_LOCATIONS = [
+        __DIR__ . '/../../autoload.php',
+        __DIR__ . '/../vendor/autoload.php',
+        __DIR__ . '/../../../vendor/autoload.php',
+        __DIR__ . '/vendor/autoload.php'
+];
+
+foreach (_AVRO_AUTOLOADER_LOCATIONS as $autoloader) {
+    if (file_exists($autoloader)) {
+        require $autoloader;
+        break;
+    }
+}
+
+if (!class_exists(\Apache\Avro\Console\GenerateCommand::class)) {
+    fwrite(STDERR, "Error: Composer autoloader not found. Run 'composer 
install' first.\n");
+    exit(1);
+}
+
+use Apache\Avro\Console\GenerateCommand;

Review Comment:
   Should those imports be upper in the file ?



##########
lang/php/lib/Generator/AvroCodeGenerator.php:
##########
@@ -0,0 +1,366 @@
+<?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\Generator;
+
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroNamedSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+use PhpParser\BuilderFactory;
+use PhpParser\Node;
+use PhpParser\Node\Scalar\String_;
+use PhpParser\Node\Stmt;
+use PhpParser\PrettyPrinter\Standard;
+
+class AvroCodeGenerator
+{
+    private BuilderFactory $factory;
+    private Standard $printer;
+
+    /** @var array<string, AvroSchema> */
+    private array $registry = [];
+
+    public function __construct()
+    {
+        $this->factory = new BuilderFactory();
+        $this->printer = new Standard(['shortArraySyntax' => true]);
+    }
+
+    /**
+     * @return array<string, string> Map of filename to file contents
+     */
+    public function translate(
+        AvroSchema $schema,
+        string $path,
+        string $phpNamespace
+    ): array {
+        $this->buildRegistry($schema);
+
+        $files = [];
+
+        foreach ($this->registry as $registeredSchema) {
+            $node = match (true) {
+                $registeredSchema instanceof AvroEnumSchema => 
$this->buildEnum(
+                    $registeredSchema,
+                    $phpNamespace,
+                    $registeredSchema->symbols()
+                ),
+                $registeredSchema instanceof AvroRecordSchema => 
$this->buildRecord(
+                    $registeredSchema,
+                    $phpNamespace
+                ),
+                default => null
+            };
+
+            if (null !== $node && $registeredSchema instanceof 
AvroNamedSchema) {
+                $filename = 
$path.'/'.ucwords($registeredSchema->name()).'.php';
+                $files[$filename] = 
"<?php\n\ndeclare(strict_types=1);\n\n{$this->printer->prettyPrint([$node])}\n";
+            }
+        }
+
+        return $files;
+    }
+
+    private function buildRegistry(AvroSchema $rootSchema): void
+    {
+        $this->registry = [];
+        $this->collectSchemas($rootSchema);
+    }
+
+    private function collectSchemas(AvroSchema $schema): void
+    {
+        switch ($schema::class) {
+            case AvroRecordSchema::class:
+                if (!array_key_exists($schema->fullname(), $this->registry)) {
+                    $this->registry[$schema->fullname()] = $schema;
+                    foreach ($schema->fields() as $field) {
+                        $this->collectSchemas($field->type());
+                    }
+                }
+
+                break;
+            case AvroEnumSchema::class:
+                $this->registry[$schema->fullname()] = $schema;
+
+                break;
+            case AvroArraySchema::class:
+                $this->collectSchemas($schema->items());
+
+                break;
+            case AvroMapSchema::class:
+                $this->collectSchemas($schema->values());
+
+                break;
+            case AvroUnionSchema::class:
+                foreach ($schema->schemas() as $unionSchema) {
+                    $this->collectSchemas($unionSchema);
+                }
+
+                break;
+        }
+    }
+
+    private function buildRecord(
+        AvroRecordSchema $avroRecord,
+        string $phpNamespace
+    ): Node {
+        $className = ucwords($avroRecord->name());
+        $class = 
$this->factory->class($className)->makeFinal()->implement('\\JsonSerializable');
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $property = $this->factory->property($field->name())
+                ->makePrivate()
+                ->setType($phpType);
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $property->setDocComment('/** @var '.$phpDocType.' */');
+            }
+
+            if ($field->hasDefaultValue()) {
+                
$property->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $class->addStmt($property);
+        }
+
+        $constructor = $this->factory->method('__construct')->makePublic();
+        $constructorParamDocs = [];
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $param = $this->factory->param($field->name())->setType($phpType);
+            if ($field->hasDefaultValue()) {
+                
$param->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $constructorParamDocs[] = '@param '.$phpDocType.' 
$'.$field->name();
+            }
+
+            $constructor->addParam($param);
+            $constructor->addStmt(
+                new Node\Expr\Assign(
+                    new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $field->name()),
+                    new Node\Expr\Variable($field->name())
+                )
+            );
+        }
+        if ([] !== $constructorParamDocs) {
+            $docLines = "/**\n";
+            foreach ($constructorParamDocs as $doc) {
+                $docLines .= ' * '.$doc."\n";
+            }
+            $docLines .= ' */';
+            $constructor->setDocComment($docLines);
+        }
+        $class->addStmt($constructor);
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $getter = $this->factory->method($field->name())
+                ->makePublic()
+                ->setReturnType($phpType)
+                ->addStmt(
+                    new Stmt\Return_(
+                        new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $field->name())
+                    )
+                );
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $getter->setDocComment('/** @return '.$phpDocType.' */');
+            }
+
+            $class->addStmt($getter);
+        }
+
+        $arrayItems = [];
+        foreach ($avroRecord->fields() as $field) {
+            $arrayItems[] = new Node\ArrayItem(
+                $this->buildJsonSerializeValue($field->type(), $field->name()),
+                new String_($field->name())
+            );
+        }
+        $jsonSerialize = $this->factory->method('jsonSerialize')
+            ->makePublic()
+            ->setReturnType('mixed')
+            ->addStmt(
+                new Stmt\Return_(
+                    new Node\Expr\Array_($arrayItems, ['kind' => 
Node\Expr\Array_::KIND_SHORT])
+                )
+            );
+        $class->addStmt($jsonSerialize);
+
+        return $this->factory->namespace($phpNamespace)
+            ->addStmt($class)
+            ->getNode();
+    }
+
+    /**
+     * Builds the expression used inside jsonSerialize() for a single field.
+     *
+     * - EnumSchema        → $this->field->value       (plain string for Avro 
+ JSON)
+     * - union[null, Enum] → $this->field?->value      (null-safe, still plain)
+     * - anything else     → $this->field
+     */
+    private function buildJsonSerializeValue(AvroSchema $fieldType, string 
$fieldName): Node\Expr
+    {
+        $propertyFetch = new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $fieldName);
+
+        if ($fieldType instanceof AvroEnumSchema) {
+            return new Node\Expr\PropertyFetch($propertyFetch, 'value');
+        }
+
+        if ($fieldType instanceof AvroUnionSchema) {
+            $nonNullSchemas = array_values(array_filter(
+                $fieldType->schemas(),
+                static fn (AvroSchema $s): bool => !($s instanceof 
AvroPrimitiveSchema && AvroSchema::NULL_TYPE === $s->type())
+            ));
+
+            if (1 === count($nonNullSchemas) && $nonNullSchemas[0] instanceof 
AvroEnumSchema) {
+                return new Node\Expr\NullsafePropertyFetch($propertyFetch, 
'value');
+            }
+        }
+
+        return $propertyFetch;
+    }
+
+    /**
+     * @param list<string> $values
+     */
+    private function buildEnum(
+        AvroEnumSchema $avroEnum,
+        string $phpNamespace,
+        array $values
+    ): Node {
+        $className = ucwords($avroEnum->name());
+        $enum = $this->factory->enum($className)->setScalarType('string');
+
+        foreach ($values as $value) {
+            $caseName = strtoupper($value);

Review Comment:
   Names in Avro are case-sensitive, i.e. schemas with names `"name"` and 
`"Name"` are OK to exist at the same time.
   `strtoupper()` will lead to a collision in the PHP class name.



##########
lang/php/lib/Console/GenerateCommand.php:
##########
@@ -0,0 +1,149 @@
+<?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\Console;
+
+use Apache\Avro\Generator\AvroCodeGenerator;
+use Apache\Avro\Schema\AvroSchema;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+#[AsCommand(
+    name: 'generate',
+    description: 'Generate PHP classes from Avro schema files',
+)]
+class GenerateCommand extends Command
+{
+    protected function configure(): void
+    {
+        $this
+            ->addOption('file', 'f', InputOption::VALUE_OPTIONAL, 'One Avro 
schema file (.avsc)')
+            ->addOption('directory', 'd', InputOption::VALUE_OPTIONAL, 'A 
directory containing multiple Avro schema files (.avsc)')
+            ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output 
directory for generated PHP files')
+            ->addOption('namespace', 'ns', InputOption::VALUE_REQUIRED, 'PHP 
namespace for generated classes');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface 
$output): int
+    {
+        $io = new SymfonyStyle($input, $output);
+
+        /** @var string $outputDir */
+        $outputDir = $input->getOption('output');
+        /** @var string $namespace */
+        $namespace = $input->getOption('namespace');
+
+        /** @var null|string $file */
+        $file = $input->getOption('file');
+        /** @var null|string $directory */
+        $directory = $input->getOption('directory');
+
+        if (
+            (null === $file && null === $directory)
+            || (null !== $file && null !== $directory)
+        ) {
+            $io->error('You must provide a file path or a directory');
+
+            return Command::FAILURE;
+        }
+
+        if (null === $outputDir || '' === $outputDir) {
+            $io->error('Output directory is required (--output / -o).');
+
+            return Command::FAILURE;
+        }
+
+        if (null === $namespace || '' === $namespace) {
+            $io->error('PHP namespace is required (--namespace / -ns).');
+
+            return Command::FAILURE;
+        }
+
+        if (!is_dir($outputDir) && !mkdir($outputDir, 0755, true) && 
!is_dir($outputDir)) {
+            $io->error(sprintf('Could not create output directory "%s".', 
$outputDir));
+
+            return Command::FAILURE;
+        }
+
+        $outputDir = rtrim((string) realpath($outputDir), '/');
+        $files = [];
+        if (null !== $file) {
+            $files[] = $file;
+        } elseif (null !== $directory) {
+            if (!is_dir($directory)) {
+                $io->error(sprintf('Directory not found: %s', $directory));
+
+                return Command::FAILURE;
+            }
+            $files = glob(rtrim($directory, '/').'/*.avsc') ?: [];
+        }
+
+        $generator = new AvroCodeGenerator();
+        $written = [];
+        $exitCode = Command::SUCCESS;
+
+        foreach ($files as $file) {
+            if (!file_exists($file)) {
+                $io->error(sprintf('File not found: %s', $file));
+                $exitCode = Command::FAILURE;
+
+                continue;
+            }
+
+            $json = file_get_contents($file);
+            if (false === $json) {
+                $io->error(sprintf('Could not read file: %s', $file));
+                $exitCode = Command::FAILURE;
+
+                continue;
+            }
+
+            try {
+                $schema = AvroSchema::parse($json);
+                $generatedFiles = $generator->translate($schema, $outputDir, 
$namespace);
+
+                foreach ($generatedFiles as $path => $content) {
+                    if (false === file_put_contents($path, $content)) {
+                        $io->error(sprintf('Could not write file: %s', $path));
+                        $exitCode = Command::FAILURE;
+
+                        continue;
+                    }
+                    $written[] = $path;
+                }
+            } catch (\Throwable $e) {
+                $io->error(sprintf('Error processing %s: %s', $file, 
$e->getMessage()));
+                $exitCode = Command::FAILURE;
+            }
+        }
+
+        if ([] !== $written) {

Review Comment:
   An empty `directory` (i.e. `files == []`) will be reported as success. Is 
this intentional ? 
   I am not sure how the other SDK generators behave.



##########
lang/php/lib/Console/GenerateCommand.php:
##########
@@ -0,0 +1,149 @@
+<?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\Console;
+
+use Apache\Avro\Generator\AvroCodeGenerator;
+use Apache\Avro\Schema\AvroSchema;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+#[AsCommand(
+    name: 'generate',
+    description: 'Generate PHP classes from Avro schema files',
+)]
+class GenerateCommand extends Command
+{
+    protected function configure(): void
+    {
+        $this
+            ->addOption('file', 'f', InputOption::VALUE_OPTIONAL, 'One Avro 
schema file (.avsc)')
+            ->addOption('directory', 'd', InputOption::VALUE_OPTIONAL, 'A 
directory containing multiple Avro schema files (.avsc)')
+            ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output 
directory for generated PHP files')
+            ->addOption('namespace', 'ns', InputOption::VALUE_REQUIRED, 'PHP 
namespace for generated classes');

Review Comment:
   ```suggestion
               ->addOption('namespace', 'n', InputOption::VALUE_REQUIRED, 'PHP 
namespace for generated classes');
   ```
   Usually the short options are single character



##########
lang/php/lib/Generator/AvroCodeGenerator.php:
##########
@@ -0,0 +1,366 @@
+<?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\Generator;
+
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroNamedSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+use PhpParser\BuilderFactory;
+use PhpParser\Node;
+use PhpParser\Node\Scalar\String_;
+use PhpParser\Node\Stmt;
+use PhpParser\PrettyPrinter\Standard;
+
+class AvroCodeGenerator
+{
+    private BuilderFactory $factory;
+    private Standard $printer;
+
+    /** @var array<string, AvroSchema> */
+    private array $registry = [];
+
+    public function __construct()
+    {
+        $this->factory = new BuilderFactory();
+        $this->printer = new Standard(['shortArraySyntax' => true]);
+    }
+
+    /**
+     * @return array<string, string> Map of filename to file contents
+     */
+    public function translate(
+        AvroSchema $schema,
+        string $path,
+        string $phpNamespace
+    ): array {
+        $this->buildRegistry($schema);
+
+        $files = [];
+
+        foreach ($this->registry as $registeredSchema) {
+            $node = match (true) {
+                $registeredSchema instanceof AvroEnumSchema => 
$this->buildEnum(
+                    $registeredSchema,
+                    $phpNamespace,
+                    $registeredSchema->symbols()
+                ),
+                $registeredSchema instanceof AvroRecordSchema => 
$this->buildRecord(
+                    $registeredSchema,
+                    $phpNamespace
+                ),
+                default => null
+            };
+
+            if (null !== $node && $registeredSchema instanceof 
AvroNamedSchema) {
+                $filename = 
$path.'/'.ucwords($registeredSchema->name()).'.php';
+                $files[$filename] = 
"<?php\n\ndeclare(strict_types=1);\n\n{$this->printer->prettyPrint([$node])}\n";
+            }
+        }
+
+        return $files;
+    }
+
+    private function buildRegistry(AvroSchema $rootSchema): void
+    {
+        $this->registry = [];
+        $this->collectSchemas($rootSchema);
+    }
+
+    private function collectSchemas(AvroSchema $schema): void
+    {
+        switch ($schema::class) {
+            case AvroRecordSchema::class:
+                if (!array_key_exists($schema->fullname(), $this->registry)) {
+                    $this->registry[$schema->fullname()] = $schema;
+                    foreach ($schema->fields() as $field) {
+                        $this->collectSchemas($field->type());
+                    }
+                }
+
+                break;
+            case AvroEnumSchema::class:
+                $this->registry[$schema->fullname()] = $schema;
+
+                break;
+            case AvroArraySchema::class:
+                $this->collectSchemas($schema->items());
+
+                break;
+            case AvroMapSchema::class:
+                $this->collectSchemas($schema->values());
+
+                break;
+            case AvroUnionSchema::class:
+                foreach ($schema->schemas() as $unionSchema) {
+                    $this->collectSchemas($unionSchema);
+                }
+
+                break;
+        }
+    }
+
+    private function buildRecord(
+        AvroRecordSchema $avroRecord,
+        string $phpNamespace
+    ): Node {
+        $className = ucwords($avroRecord->name());
+        $class = 
$this->factory->class($className)->makeFinal()->implement('\\JsonSerializable');
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $property = $this->factory->property($field->name())
+                ->makePrivate()
+                ->setType($phpType);
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $property->setDocComment('/** @var '.$phpDocType.' */');
+            }
+
+            if ($field->hasDefaultValue()) {
+                
$property->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $class->addStmt($property);
+        }
+
+        $constructor = $this->factory->method('__construct')->makePublic();
+        $constructorParamDocs = [];
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $param = $this->factory->param($field->name())->setType($phpType);
+            if ($field->hasDefaultValue()) {
+                
$param->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $constructorParamDocs[] = '@param '.$phpDocType.' 
$'.$field->name();
+            }
+
+            $constructor->addParam($param);
+            $constructor->addStmt(
+                new Node\Expr\Assign(
+                    new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $field->name()),
+                    new Node\Expr\Variable($field->name())
+                )
+            );
+        }
+        if ([] !== $constructorParamDocs) {
+            $docLines = "/**\n";
+            foreach ($constructorParamDocs as $doc) {
+                $docLines .= ' * '.$doc."\n";
+            }
+            $docLines .= ' */';
+            $constructor->setDocComment($docLines);
+        }
+        $class->addStmt($constructor);
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $getter = $this->factory->method($field->name())
+                ->makePublic()
+                ->setReturnType($phpType)
+                ->addStmt(
+                    new Stmt\Return_(
+                        new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $field->name())
+                    )
+                );
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $getter->setDocComment('/** @return '.$phpDocType.' */');
+            }
+
+            $class->addStmt($getter);
+        }
+
+        $arrayItems = [];
+        foreach ($avroRecord->fields() as $field) {
+            $arrayItems[] = new Node\ArrayItem(

Review Comment:
   Shouldn't this be:
   
   ```suggestion
               $arrayItems[] = new Node\Expr\ArrayItem(
   ```
   ?
   
   https://apiref.phpstan.org/1.8.x/PhpParser.Node.Expr.ArrayItem.html



##########
lang/php/test/Console/GenerateCommandTest.php:
##########
@@ -0,0 +1,227 @@
+<?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\Tests\Console;
+
+use Apache\Avro\Console\GenerateCommand;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Tester\CommandTester;
+
+class GenerateCommandTest extends TestCase
+{
+    private string $outputDir;
+
+    protected function setUp(): void
+    {
+        $this->outputDir = sys_get_temp_dir().'/avro_test_'.uniqid();
+    }
+
+    protected function tearDown(): void
+    {
+        if (is_dir($this->outputDir)) {
+            $this->removeDir($this->outputDir);
+        }
+    }
+
+    #[Test]
+    public function fails_when_no_input_provided(): void
+    {
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--output' => $this->outputDir,
+            '--namespace' => 'App\\Generated',
+        ]);
+
+        self::assertSame(Command::FAILURE, $exitCode);
+        self::assertStringContainsString('You must provide a file path or a 
directory', $tester->getDisplay());
+    }
+
+    #[Test]
+    public function fails_when_both_file_and_directory_provided(): void
+    {
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--file' => $this->schemaPath('user.avsc'),
+            '--directory' => __DIR__.'/../Fixtures/Schemas',
+            '--output' => $this->outputDir,
+            '--namespace' => 'App\\Generated',
+        ]);
+
+        self::assertSame(Command::FAILURE, $exitCode);
+        self::assertStringContainsString('You must provide a file path or a 
directory', $tester->getDisplay());
+    }
+
+    #[Test]
+    public function fails_when_output_is_missing(): void
+    {
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--file' => $this->schemaPath('user.avsc'),
+            '--namespace' => 'App\\Generated',
+        ]);
+
+        self::assertSame(Command::FAILURE, $exitCode);
+        self::assertStringContainsString('Output directory is required', 
$tester->getDisplay());
+    }
+
+    #[Test]
+    public function fails_when_namespace_is_missing(): void
+    {
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--file' => $this->schemaPath('user.avsc'),
+            '--output' => $this->outputDir,
+        ]);
+
+        self::assertSame(Command::FAILURE, $exitCode);
+        self::assertStringContainsString('PHP namespace is required', 
$tester->getDisplay());
+    }
+
+    #[Test]
+    public function fails_when_file_does_not_exist(): void
+    {
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--file' => '/nonexistent/path/schema.avsc',
+            '--output' => $this->outputDir,
+            '--namespace' => 'App\\Generated',
+        ]);
+
+        self::assertSame(Command::FAILURE, $exitCode);
+        self::assertStringContainsString('File not found', 
$tester->getDisplay());
+    }
+
+    #[Test]
+    public function fails_when_directory_does_not_exist(): void
+    {
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--directory' => '/nonexistent/directory',
+            '--output' => $this->outputDir,
+            '--namespace' => 'App\\Generated',
+        ]);
+
+        self::assertSame(Command::FAILURE, $exitCode);
+        self::assertStringContainsString('Directory not found', 
$tester->getDisplay());
+    }
+
+    #[Test]
+    public function generates_php_class_from_single_schema_file(): void
+    {
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--file' => $this->schemaPath('user.avsc'),
+            '--output' => $this->outputDir,
+            '--namespace' => 'App\\Generated',
+        ]);
+
+        self::assertSame(Command::SUCCESS, $exitCode);
+        self::assertFileExists($this->outputDir.'/User.php');
+        self::assertStringContainsString('1 file(s) generated', 
$tester->getDisplay());
+    }
+
+    #[Test]
+    public function generates_php_files_from_schema_directory(): void
+    {
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--directory' => __DIR__.'/../Fixtures/Schemas',
+            '--output' => $this->outputDir,
+            '--namespace' => 'App\\Generated',
+        ]);
+
+        self::assertSame(Command::SUCCESS, $exitCode);
+        self::assertFileExists($this->outputDir.'/User.php');
+        self::assertFileExists($this->outputDir.'/Status.php');
+        self::assertStringContainsString('2 file(s) generated', 
$tester->getDisplay());
+    }
+
+    #[Test]
+    public function creates_output_directory_when_it_does_not_exist(): void
+    {
+        $nestedOutputDir = $this->outputDir.'/nested/path';
+
+        $tester = $this->tester();
+        $exitCode = $tester->execute([
+            '--file' => $this->schemaPath('user.avsc'),
+            '--output' => $nestedOutputDir,
+            '--namespace' => 'App\\Generated',
+        ]);
+
+        self::assertSame(Command::SUCCESS, $exitCode);
+        self::assertDirectoryExists($nestedOutputDir);
+        self::assertFileExists($nestedOutputDir.'/User.php');
+    }
+
+    #[Test]
+    public function generated_file_contains_correct_namespace_and_class(): void
+    {
+        $tester = $this->tester();
+        $tester->execute([
+            '--file' => $this->schemaPath('user.avsc'),
+            '--output' => $this->outputDir,
+            '--namespace' => 'My\\App\\Avro',
+        ]);
+

Review Comment:
   ```suggestion
           $exitCode = $tester->execute([
               '--file' => $this->schemaPath('user.avsc'),
               '--output' => $this->outputDir,
               '--namespace' => 'My\\App\\Avro',
           ]);
           self::assertSame(Command::SUCCESS, $exitCode);
           self::assertFileExists($this->outputDir.'/User.php');
   
   ```



##########
lang/php/lib/Generator/AvroCodeGenerator.php:
##########
@@ -0,0 +1,366 @@
+<?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\Generator;
+
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroNamedSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+use PhpParser\BuilderFactory;
+use PhpParser\Node;
+use PhpParser\Node\Scalar\String_;
+use PhpParser\Node\Stmt;
+use PhpParser\PrettyPrinter\Standard;
+
+class AvroCodeGenerator
+{
+    private BuilderFactory $factory;
+    private Standard $printer;
+
+    /** @var array<string, AvroSchema> */
+    private array $registry = [];
+
+    public function __construct()
+    {
+        $this->factory = new BuilderFactory();
+        $this->printer = new Standard(['shortArraySyntax' => true]);
+    }
+
+    /**
+     * @return array<string, string> Map of filename to file contents
+     */
+    public function translate(
+        AvroSchema $schema,
+        string $path,
+        string $phpNamespace
+    ): array {
+        $this->buildRegistry($schema);
+
+        $files = [];
+
+        foreach ($this->registry as $registeredSchema) {
+            $node = match (true) {
+                $registeredSchema instanceof AvroEnumSchema => 
$this->buildEnum(
+                    $registeredSchema,
+                    $phpNamespace,
+                    $registeredSchema->symbols()
+                ),
+                $registeredSchema instanceof AvroRecordSchema => 
$this->buildRecord(
+                    $registeredSchema,
+                    $phpNamespace
+                ),
+                default => null
+            };
+
+            if (null !== $node && $registeredSchema instanceof 
AvroNamedSchema) {
+                $filename = 
$path.'/'.ucwords($registeredSchema->name()).'.php';
+                $files[$filename] = 
"<?php\n\ndeclare(strict_types=1);\n\n{$this->printer->prettyPrint([$node])}\n";
+            }
+        }
+
+        return $files;
+    }
+
+    private function buildRegistry(AvroSchema $rootSchema): void
+    {
+        $this->registry = [];
+        $this->collectSchemas($rootSchema);
+    }
+
+    private function collectSchemas(AvroSchema $schema): void
+    {
+        switch ($schema::class) {
+            case AvroRecordSchema::class:
+                if (!array_key_exists($schema->fullname(), $this->registry)) {
+                    $this->registry[$schema->fullname()] = $schema;
+                    foreach ($schema->fields() as $field) {
+                        $this->collectSchemas($field->type());
+                    }
+                }
+
+                break;
+            case AvroEnumSchema::class:
+                $this->registry[$schema->fullname()] = $schema;
+
+                break;
+            case AvroArraySchema::class:
+                $this->collectSchemas($schema->items());
+
+                break;
+            case AvroMapSchema::class:
+                $this->collectSchemas($schema->values());
+
+                break;
+            case AvroUnionSchema::class:
+                foreach ($schema->schemas() as $unionSchema) {
+                    $this->collectSchemas($unionSchema);
+                }
+
+                break;
+        }
+    }
+
+    private function buildRecord(
+        AvroRecordSchema $avroRecord,
+        string $phpNamespace
+    ): Node {
+        $className = ucwords($avroRecord->name());
+        $class = 
$this->factory->class($className)->makeFinal()->implement('\\JsonSerializable');
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $property = $this->factory->property($field->name())
+                ->makePrivate()
+                ->setType($phpType);
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $property->setDocComment('/** @var '.$phpDocType.' */');
+            }
+
+            if ($field->hasDefaultValue()) {
+                
$property->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $class->addStmt($property);
+        }
+
+        $constructor = $this->factory->method('__construct')->makePublic();
+        $constructorParamDocs = [];
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $param = $this->factory->param($field->name())->setType($phpType);
+            if ($field->hasDefaultValue()) {
+                
$param->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $constructorParamDocs[] = '@param '.$phpDocType.' 
$'.$field->name();
+            }
+
+            $constructor->addParam($param);
+            $constructor->addStmt(
+                new Node\Expr\Assign(
+                    new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $field->name()),
+                    new Node\Expr\Variable($field->name())
+                )
+            );
+        }
+        if ([] !== $constructorParamDocs) {
+            $docLines = "/**\n";
+            foreach ($constructorParamDocs as $doc) {
+                $docLines .= ' * '.$doc."\n";
+            }
+            $docLines .= ' */';
+            $constructor->setDocComment($docLines);
+        }
+        $class->addStmt($constructor);
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $getter = $this->factory->method($field->name())
+                ->makePublic()
+                ->setReturnType($phpType)
+                ->addStmt(
+                    new Stmt\Return_(
+                        new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $field->name())
+                    )
+                );
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $getter->setDocComment('/** @return '.$phpDocType.' */');
+            }
+
+            $class->addStmt($getter);
+        }
+
+        $arrayItems = [];
+        foreach ($avroRecord->fields() as $field) {
+            $arrayItems[] = new Node\ArrayItem(
+                $this->buildJsonSerializeValue($field->type(), $field->name()),
+                new String_($field->name())
+            );
+        }
+        $jsonSerialize = $this->factory->method('jsonSerialize')
+            ->makePublic()
+            ->setReturnType('mixed')
+            ->addStmt(
+                new Stmt\Return_(
+                    new Node\Expr\Array_($arrayItems, ['kind' => 
Node\Expr\Array_::KIND_SHORT])
+                )
+            );
+        $class->addStmt($jsonSerialize);
+
+        return $this->factory->namespace($phpNamespace)
+            ->addStmt($class)
+            ->getNode();
+    }
+
+    /**
+     * Builds the expression used inside jsonSerialize() for a single field.
+     *
+     * - EnumSchema        → $this->field->value       (plain string for Avro 
+ JSON)
+     * - union[null, Enum] → $this->field?->value      (null-safe, still plain)
+     * - anything else     → $this->field
+     */
+    private function buildJsonSerializeValue(AvroSchema $fieldType, string 
$fieldName): Node\Expr
+    {
+        $propertyFetch = new Node\Expr\PropertyFetch(new 
Node\Expr\Variable('this'), $fieldName);
+
+        if ($fieldType instanceof AvroEnumSchema) {
+            return new Node\Expr\PropertyFetch($propertyFetch, 'value');
+        }
+
+        if ($fieldType instanceof AvroUnionSchema) {
+            $nonNullSchemas = array_values(array_filter(
+                $fieldType->schemas(),
+                static fn (AvroSchema $s): bool => !($s instanceof 
AvroPrimitiveSchema && AvroSchema::NULL_TYPE === $s->type())
+            ));
+
+            if (1 === count($nonNullSchemas) && $nonNullSchemas[0] instanceof 
AvroEnumSchema) {
+                return new Node\Expr\NullsafePropertyFetch($propertyFetch, 
'value');
+            }
+        }
+
+        return $propertyFetch;
+    }
+
+    /**
+     * @param list<string> $values
+     */
+    private function buildEnum(
+        AvroEnumSchema $avroEnum,
+        string $phpNamespace,
+        array $values
+    ): Node {
+        $className = ucwords($avroEnum->name());
+        $enum = $this->factory->enum($className)->setScalarType('string');
+
+        foreach ($values as $value) {
+            $caseName = strtoupper($value);
+            $enum->addStmt(
+                $this->factory->enumCase($caseName)->setValue($value)
+            );
+        }
+
+        return $this->factory->namespace($phpNamespace)
+            ->addStmt($enum)
+            ->getNode();
+    }
+
+    private function avroTypeToPhp(AvroSchema $schema, string $phpNamespace): 
string
+    {
+        return match (true) {
+            $schema instanceof AvroPrimitiveSchema => 
$this->avroPrimitiveTypeToPhp($schema),
+            $schema instanceof AvroArraySchema, $schema instanceof 
AvroMapSchema => 'array',
+            $schema instanceof AvroRecordSchema, $schema instanceof 
AvroEnumSchema => '\\'.$phpNamespace.'\\'.ucwords($schema->name()),
+            $schema instanceof AvroUnionSchema => $this->unionToPhp($schema, 
$phpNamespace),
+            default => 'mixed'
+        };
+    }
+
+    private function avroPrimitiveTypeToPhp(AvroPrimitiveSchema 
$primitiveSchema): string
+    {
+        return match ($primitiveSchema->type()) {
+            AvroSchema::NULL_TYPE => 'null',

Review Comment:
   Is this correct ?
   It will generate something like `private null $x`, no ?



##########
lang/php/lib/Generator/AvroCodeGenerator.php:
##########
@@ -0,0 +1,366 @@
+<?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\Generator;
+
+use Apache\Avro\Schema\AvroArraySchema;
+use Apache\Avro\Schema\AvroEnumSchema;
+use Apache\Avro\Schema\AvroMapSchema;
+use Apache\Avro\Schema\AvroNamedSchema;
+use Apache\Avro\Schema\AvroPrimitiveSchema;
+use Apache\Avro\Schema\AvroRecordSchema;
+use Apache\Avro\Schema\AvroSchema;
+use Apache\Avro\Schema\AvroUnionSchema;
+use PhpParser\BuilderFactory;
+use PhpParser\Node;
+use PhpParser\Node\Scalar\String_;
+use PhpParser\Node\Stmt;
+use PhpParser\PrettyPrinter\Standard;
+
+class AvroCodeGenerator
+{
+    private BuilderFactory $factory;
+    private Standard $printer;
+
+    /** @var array<string, AvroSchema> */
+    private array $registry = [];
+
+    public function __construct()
+    {
+        $this->factory = new BuilderFactory();
+        $this->printer = new Standard(['shortArraySyntax' => true]);
+    }
+
+    /**
+     * @return array<string, string> Map of filename to file contents
+     */
+    public function translate(
+        AvroSchema $schema,
+        string $path,
+        string $phpNamespace
+    ): array {
+        $this->buildRegistry($schema);
+
+        $files = [];
+
+        foreach ($this->registry as $registeredSchema) {
+            $node = match (true) {
+                $registeredSchema instanceof AvroEnumSchema => 
$this->buildEnum(
+                    $registeredSchema,
+                    $phpNamespace,
+                    $registeredSchema->symbols()
+                ),
+                $registeredSchema instanceof AvroRecordSchema => 
$this->buildRecord(
+                    $registeredSchema,
+                    $phpNamespace
+                ),
+                default => null
+            };
+
+            if (null !== $node && $registeredSchema instanceof 
AvroNamedSchema) {
+                $filename = 
$path.'/'.ucwords($registeredSchema->name()).'.php';
+                $files[$filename] = 
"<?php\n\ndeclare(strict_types=1);\n\n{$this->printer->prettyPrint([$node])}\n";
+            }
+        }
+
+        return $files;
+    }
+
+    private function buildRegistry(AvroSchema $rootSchema): void
+    {
+        $this->registry = [];
+        $this->collectSchemas($rootSchema);
+    }
+
+    private function collectSchemas(AvroSchema $schema): void
+    {
+        switch ($schema::class) {
+            case AvroRecordSchema::class:
+                if (!array_key_exists($schema->fullname(), $this->registry)) {
+                    $this->registry[$schema->fullname()] = $schema;
+                    foreach ($schema->fields() as $field) {
+                        $this->collectSchemas($field->type());
+                    }
+                }
+
+                break;
+            case AvroEnumSchema::class:
+                $this->registry[$schema->fullname()] = $schema;
+
+                break;
+            case AvroArraySchema::class:
+                $this->collectSchemas($schema->items());
+
+                break;
+            case AvroMapSchema::class:
+                $this->collectSchemas($schema->values());
+
+                break;
+            case AvroUnionSchema::class:
+                foreach ($schema->schemas() as $unionSchema) {
+                    $this->collectSchemas($unionSchema);
+                }
+
+                break;
+        }
+    }
+
+    private function buildRecord(
+        AvroRecordSchema $avroRecord,
+        string $phpNamespace
+    ): Node {
+        $className = ucwords($avroRecord->name());
+        $class = 
$this->factory->class($className)->makeFinal()->implement('\\JsonSerializable');
+
+        foreach ($avroRecord->fields() as $field) {
+            $phpType = $this->avroTypeToPhp($field->type(), $phpNamespace);
+            $property = $this->factory->property($field->name())
+                ->makePrivate()
+                ->setType($phpType);
+
+            $phpDocType = $this->avroTypeToPhpDoc($field->type(), 
$phpNamespace);
+            if (null !== $phpDocType) {
+                $property->setDocComment('/** @var '.$phpDocType.' */');
+            }
+
+            if ($field->hasDefaultValue()) {
+                
$property->setDefault($this->buildDefault($field->defaultValue()));
+            }
+
+            $class->addStmt($property);
+        }
+
+        $constructor = $this->factory->method('__construct')->makePublic();
+        $constructorParamDocs = [];
+        foreach ($avroRecord->fields() as $field) {

Review Comment:
   Also, this will probably work for fields with primitive types but will it 
work for complex types too, like Map, Array ?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to