Support custom JSON enum names in PHP

This CL adds support for the Edition 2026 custom JSON enum name feature (pb.enumvalue.json) in PHP Protobuf.

Note that we don't need extension caching similar to C++ or Java because our PHP code generator collects custom JSON names at protoc compile time and passes them to DescriptorPool::internalAddGeneratedFile(), populating EnumValueDescriptor and EnumDescriptor during descriptor registration.

Tested with PHP unit tests across pure-PHP and C-extension modes.

PiperOrigin-RevId: 963411589
diff --git a/php/BUILD.bazel b/php/BUILD.bazel
index 5bad261..5546eea 100644
--- a/php/BUILD.bazel
+++ b/php/BUILD.bazel
@@ -27,6 +27,7 @@
     "src/Google/Protobuf/Internal/DescriptorPool.php",
     "src/Google/Protobuf/Internal/EnumBuilderContext.php",
     "src/Google/Protobuf/Internal/EnumDescriptor.php",
+    "src/Google/Protobuf/Internal/EnumValueDescriptor.php",
     "src/Google/Protobuf/Internal/FieldDescriptor.php",
     "src/Google/Protobuf/Internal/FileDescriptor.php",
     "src/Google/Protobuf/Internal/GetPublicDescriptorTrait.php",
diff --git a/php/ext/google/protobuf/def.c b/php/ext/google/protobuf/def.c
index 0d8ad82..a7937d4 100644
--- a/php/ext/google/protobuf/def.c
+++ b/php/ext/google/protobuf/def.c
@@ -1020,13 +1020,20 @@
   char* data = NULL;
   zend_long data_len;
   zend_bool use_nested_submsg = false;
+  zval* custom_json_names = NULL;
   upb_Arena* arena;
 
-  if (zend_parse_parameters(ZEND_NUM_ARGS(), "s|b", &data, &data_len,
-                            &use_nested_submsg) != SUCCESS) {
+  if (zend_parse_parameters(ZEND_NUM_ARGS(), "s|bz", &data, &data_len,
+                            &use_nested_submsg,
+                            &custom_json_names) != SUCCESS) {
     return;
   }
 
+  // custom_json_names is passed by generated PHP metadata for pure-PHP mode,
+  // but is unused in the C extension because upb parses enum JSON names
+  // directly from the descriptor data.
+  (void)custom_json_names;
+
   arena = upb_Arena_New();
   add_descriptor_set(intern->symtab, data, data_len, arena);
   upb_Arena_Free(arena);
@@ -1037,9 +1044,10 @@
   ZEND_ARG_INFO(0, name)
 ZEND_END_ARG_INFO()
 
-ZEND_BEGIN_ARG_INFO_EX(arginfo_addgeneratedfile, 0, 0, 2)
+ZEND_BEGIN_ARG_INFO_EX(arginfo_addgeneratedfile, 0, 0, 1)
   ZEND_ARG_INFO(0, data)
-  ZEND_ARG_INFO(0, data_len)
+  ZEND_ARG_INFO(0, use_nested_submsg)
+  ZEND_ARG_INFO(0, custom_json_names)
 ZEND_END_ARG_INFO()
 
 static zend_function_entry DescriptorPool_methods[] = {
diff --git a/php/generate_test_protos.sh b/php/generate_test_protos.sh
index 51df6f8..104a320 100755
--- a/php/generate_test_protos.sh
+++ b/php/generate_test_protos.sh
@@ -26,6 +26,13 @@
 
 find php/tests/proto -type f -name "*.proto"| xargs $PROTOC --php_out=php/tmp -Isrc -Iphp/tests
 
+# Needed because any test proto importing json_options / json_enumvalue_options
+# will invoke \GPBMetadata\Google\Protobuf\JsonOptions::initOnce() in its
+# generated metadata class.
+JSON_OPTIONS_FILES="src/google/protobuf/json_options.proto \
+    src/google/protobuf/json_enumvalue_options.proto"
+$PROTOC --php_out=php/tmp -Isrc -Iphp/tests $JSON_OPTIONS_FILES
+
 if [ "$1" = "--aggregate_metadata" ]; then
   # Overwrite some of the files to use aggregation.
   AGGREGATED_FILES="tests/proto/test.proto tests/proto/test_include.proto tests/proto/test_import_descriptor_proto.proto"
diff --git a/php/src/Google/Protobuf/EnumDescriptor.php b/php/src/Google/Protobuf/EnumDescriptor.php
index 39c145d..df37cee 100644
--- a/php/src/Google/Protobuf/EnumDescriptor.php
+++ b/php/src/Google/Protobuf/EnumDescriptor.php
@@ -9,8 +9,12 @@
 
 namespace Google\Protobuf;
 
+use Google\Protobuf\Internal\GetPublicDescriptorTrait;
+
 class EnumDescriptor
 {
+    use GetPublicDescriptorTrait;
+
     private $internal_desc;
 
     /**
@@ -43,7 +47,9 @@
      */
     public function getValue($index)
     {
-        return $this->internal_desc->getValueDescriptorByIndex($index);
+        return $this->getPublicDescriptor(
+            $this->internal_desc->getValueDescriptorByIndex($index)
+        );
     }
 
     /**
diff --git a/php/src/Google/Protobuf/EnumValueDescriptor.php b/php/src/Google/Protobuf/EnumValueDescriptor.php
index a1ee10a..eef5145 100644
--- a/php/src/Google/Protobuf/EnumValueDescriptor.php
+++ b/php/src/Google/Protobuf/EnumValueDescriptor.php
@@ -11,16 +11,14 @@
 
 class EnumValueDescriptor
 {
-    private $name;
-    private $number;
+    private $internal_desc;
 
     /**
      * @internal
      */
-    public function __construct($name, $number)
+    public function __construct($internal_desc)
     {
-        $this->name = $name;
-        $this->number = $number;
+        $this->internal_desc = $internal_desc;
     }
 
     /**
@@ -28,7 +26,7 @@
      */
     public function getName()
     {
-        return $this->name;
+        return $this->internal_desc->getName();
     }
 
     /**
@@ -36,6 +34,6 @@
      */
     public function getNumber()
     {
-        return $this->number;
+        return $this->internal_desc->getNumber();
     }
 }
diff --git a/php/src/Google/Protobuf/Internal/Descriptor.php b/php/src/Google/Protobuf/Internal/Descriptor.php
index bb4adaa..24da793 100644
--- a/php/src/Google/Protobuf/Internal/Descriptor.php
+++ b/php/src/Google/Protobuf/Internal/Descriptor.php
@@ -160,7 +160,7 @@
         return $this->options;
     }
 
-    public static function buildFromProto($proto, $file_proto, $containing)
+    public static function buildFromProto($proto, $file_proto, $containing, $custom_json_names = [])
     {
         $desc = new Descriptor();
 
@@ -191,13 +191,13 @@
         // Handle nested types.
         foreach ($proto->getNestedType() as $nested_proto) {
             $desc->addNestedType(Descriptor::buildFromProto(
-              $nested_proto, $file_proto, $message_name_without_package));
+              $nested_proto, $file_proto, $message_name_without_package, $custom_json_names));
         }
 
         // Handle nested enum.
         foreach ($proto->getEnumType() as $enum_proto) {
             $desc->addEnumType(EnumDescriptor::buildFromProto(
-              $enum_proto, $file_proto, $message_name_without_package));
+              $enum_proto, $file_proto, $message_name_without_package, $custom_json_names));
         }
 
         // Handle oneof fields.
diff --git a/php/src/Google/Protobuf/Internal/DescriptorPool.php b/php/src/Google/Protobuf/Internal/DescriptorPool.php
index ca8e47c..cb08915 100644
--- a/php/src/Google/Protobuf/Internal/DescriptorPool.php
+++ b/php/src/Google/Protobuf/Internal/DescriptorPool.php
@@ -33,13 +33,13 @@
         return self::$pool;
     }
 
-    public function internalAddGeneratedFile($data, $use_nested = false)
+    public function internalAddGeneratedFile($data, $use_nested = false, $custom_json_names = [])
     {
         $files = new FileDescriptorSet();
         $files->mergeFromString($data);
 
         foreach($files->getFile() as $file_proto) {
-            $file = FileDescriptor::buildFromProto($file_proto);
+            $file = FileDescriptor::buildFromProto($file_proto, $custom_json_names);
 
             foreach ($file->getMessageType() as $desc) {
                 $this->addDescriptor($desc);
diff --git a/php/src/Google/Protobuf/Internal/EnumBuilderContext.php b/php/src/Google/Protobuf/Internal/EnumBuilderContext.php
index 5df99a5..6d61735 100644
--- a/php/src/Google/Protobuf/Internal/EnumBuilderContext.php
+++ b/php/src/Google/Protobuf/Internal/EnumBuilderContext.php
@@ -10,7 +10,6 @@
 namespace Google\Protobuf\Internal;
 
 use Google\Protobuf\Internal\EnumDescriptor;
-use Google\Protobuf\EnumValueDescriptor;
 
 class EnumBuilderContext
 {
@@ -26,10 +25,10 @@
         $this->pool = $pool;
     }
 
-    public function value($name, $number)
+    public function value($name, $number, $custom_json_name = null)
     {
-        $value = new EnumValueDescriptor($name, $number);
-        $this->descriptor->addValue($number, $value);
+        $value = new EnumValueDescriptor($name, $number, $custom_json_name);
+        $this->descriptor->addValue($value);
         return $this;
     }
 
diff --git a/php/src/Google/Protobuf/Internal/EnumDescriptor.php b/php/src/Google/Protobuf/Internal/EnumDescriptor.php
index 383f53b..556c909 100644
--- a/php/src/Google/Protobuf/Internal/EnumDescriptor.php
+++ b/php/src/Google/Protobuf/Internal/EnumDescriptor.php
@@ -2,8 +2,6 @@
 
 namespace Google\Protobuf\Internal;
 
-use Google\Protobuf\EnumValueDescriptor;
-
 class EnumDescriptor
 {
     use HasPublicDescriptorTrait;
@@ -13,6 +11,7 @@
     private $full_name;
     private $value;
     private $name_to_value;
+    private $custom_name_to_value = [];
     private $value_descriptor = [];
 
     public function __construct()
@@ -30,11 +29,19 @@
         return $this->full_name;
     }
 
-    public function addValue($number, $value)
+    /**
+     * @param EnumValueDescriptor $value
+     */
+    public function addValue(EnumValueDescriptor $value)
     {
-        $this->value[$number] = $value;
+        $this->value[$value->getNumber()] = $value;
         $this->name_to_value[$value->getName()] = $value;
-        $this->value_descriptor[] = new EnumValueDescriptor($value->getName(), $number);
+        $this->value_descriptor[] = $value;
+
+        $custom_name = $value->getCustomJsonName();
+        if ($custom_name !== null) {
+            $this->custom_name_to_value[$custom_name] = $value;
+        }
     }
 
     public function getValueByNumber($number)
@@ -53,6 +60,20 @@
         return null;
     }
 
+    /**
+     * Looks up an enum value descriptor by its JSON name.
+     *
+     * @param string $name
+     * @return EnumValueDescriptor|null
+     */
+    public function getValueByJsonName($name)
+    {
+        if (isset($this->custom_name_to_value[$name])) {
+            return $this->custom_name_to_value[$name];
+        }
+        return $this->getValueByName($name);
+    }
+
     public function getValueDescriptorByIndex($index)
     {
         if (isset($this->value_descriptor[$index])) {
@@ -86,7 +107,7 @@
         return $this->legacy_klass;
     }
 
-    public static function buildFromProto($proto, $file_proto, $containing)
+    public static function buildFromProto($proto, $file_proto, $containing, $custom_json_names = [])
     {
         $desc = new EnumDescriptor();
 
@@ -108,7 +129,10 @@
         $desc->setLegacyClass($legacy_classname);
         $values = $proto->getValue();
         foreach ($values as $value) {
-            $desc->addValue($value->getNumber(), $value);
+            $val_fqn = $fullname . '.' . $value->getName();
+            $custom_json_name = $custom_json_names[$val_fqn] ?? null;
+            $desc->addValue(new EnumValueDescriptor(
+                $value->getName(), $value->getNumber(), $custom_json_name));
         }
 
         return $desc;
diff --git a/php/src/Google/Protobuf/Internal/EnumValueDescriptor.php b/php/src/Google/Protobuf/Internal/EnumValueDescriptor.php
new file mode 100644
index 0000000..ec8de23
--- /dev/null
+++ b/php/src/Google/Protobuf/Internal/EnumValueDescriptor.php
@@ -0,0 +1,48 @@
+<?php
+
+// Protocol Buffers - Google's data interchange format
+// Copyright 2008 Google Inc.  All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+namespace Google\Protobuf\Internal;
+
+class EnumValueDescriptor
+{
+    use HasPublicDescriptorTrait;
+
+    private $name;
+    private $number;
+    private $custom_json_name;
+
+    public function __construct($name, $number, $custom_json_name = null)
+    {
+        $this->name = $name;
+        $this->number = $number;
+        $this->custom_json_name = $custom_json_name;
+        $this->public_desc = new \Google\Protobuf\EnumValueDescriptor($this);
+    }
+
+    public function getName()
+    {
+        return $this->name;
+    }
+
+    public function getNumber()
+    {
+        return $this->number;
+    }
+
+    /**
+     * Returns the explicit custom JSON name if specified via
+     * (pb.enumvalue.json), or null if no custom JSON name is set.
+     *
+     * @return string|null
+     */
+    public function getCustomJsonName()
+    {
+        return $this->custom_json_name;
+    }
+}
diff --git a/php/src/Google/Protobuf/Internal/FileDescriptor.php b/php/src/Google/Protobuf/Internal/FileDescriptor.php
index a81cef1..3e281d0 100644
--- a/php/src/Google/Protobuf/Internal/FileDescriptor.php
+++ b/php/src/Google/Protobuf/Internal/FileDescriptor.php
@@ -46,20 +46,21 @@
         $this->enum_type[]= $desc;
     }
 
-    public static function buildFromProto($proto)
+    public static function buildFromProto($proto, $custom_json_names = [])
     {
         $file = new FileDescriptor();
         $file->setPackage($proto->getPackage());
         foreach ($proto->getMessageType() as $message_proto) {
             $file->addMessageType(Descriptor::buildFromProto(
-                $message_proto, $proto, ""));
+                $message_proto, $proto, "", $custom_json_names));
         }
         foreach ($proto->getEnumType() as $enum_proto) {
             $file->addEnumType(
                 EnumDescriptor::buildFromProto(
                     $enum_proto,
                     $proto,
-                    ""));
+                    "",
+                    $custom_json_names));
         }
         return $file;
     }
diff --git a/php/src/Google/Protobuf/Internal/GPBJsonWire.php b/php/src/Google/Protobuf/Internal/GPBJsonWire.php
index 1219578..08f6fd9 100644
--- a/php/src/Google/Protobuf/Internal/GPBJsonWire.php
+++ b/php/src/Google/Protobuf/Internal/GPBJsonWire.php
@@ -187,10 +187,10 @@
                 }
                 $enum_value_desc = $enum_desc->getValueByNumber($value);
                 if (!is_null($enum_value_desc)) {
-                    $str_value = $enum_value_desc->getName();
-                    $output->writeRaw("\"", 1);
+                    $str_value = json_encode(
+                        self::formatEnumValueName($enum_value_desc),
+                        JSON_UNESCAPED_UNICODE);
                     $output->writeRaw($str_value, strlen($str_value));
-                    $output->writeRaw("\"", 1);
                 } else {
                     $str_value = strval($value);
                     $output->writeRaw($str_value, strlen($str_value));
@@ -235,6 +235,13 @@
         return $field->getJsonName();
     }
 
+    public static function formatEnumValueName($enum_value_desc)
+    {
+        $custom_name = $enum_value_desc->getCustomJsonName();
+        return $custom_name !== null
+            ? $custom_name : $enum_value_desc->getName();
+    }
+
     // Used for escaping control chars in strings.
     private static $k_control_char_limit = 0x20;
 
diff --git a/php/src/Google/Protobuf/Internal/Message.php b/php/src/Google/Protobuf/Internal/Message.php
index 892456d..fd5928f 100644
--- a/php/src/Google/Protobuf/Internal/Message.php
+++ b/php/src/Google/Protobuf/Internal/Message.php
@@ -865,9 +865,10 @@
                 if (is_integer($value)) {
                     return $value;
                 }
-                $enum_value = $field->getEnumType()->getValueByName($value);
-                if (!is_null($enum_value)) {
-                    return $enum_value->getNumber();
+                $enum_desc = $field->getEnumType();
+                $enum_value_desc = $enum_desc->getValueByJsonName($value);
+                if (!is_null($enum_value_desc)) {
+                    return $enum_value_desc->getNumber();
                 } else if ($ignore_unknown) {
                     return $this->defaultValue($field);
                 } else {
@@ -1272,7 +1273,7 @@
                     if ($value_field->getType() == GPBType::ENUM &&
                         is_string($tmp_value) &&
                         is_null(
-                          $value_field->getEnumType()->getValueByName($tmp_value)
+                          $value_field->getEnumType()->getValueByJsonName($tmp_value)
                         ) &&
                         $ignore_unknown) {
                         continue;
@@ -1298,7 +1299,7 @@
                     // ignored if ignore_unknown is set.
                     if ($field->getType() == GPBType::ENUM &&
                         is_string($tmp) &&
-                        is_null($field->getEnumType()->getValueByName($tmp)) &&
+                        is_null($field->getEnumType()->getValueByJsonName($tmp)) &&
                         $ignore_unknown) {
                         continue;
                     }
@@ -1774,8 +1775,11 @@
                 } else {
                     $enum_value_desc = $enum_desc->getValueByNumber($value);
                     if (!is_null($enum_value_desc)) {
-                        $size += 2;  // size for ""
-                        $size += strlen($enum_value_desc->getName());
+                        $name = GPBJsonWire::formatEnumValueName($enum_value_desc);
+                        $encoded = json_encode(
+                            $name,
+                            JSON_UNESCAPED_UNICODE);
+                        $size += strlen($encoded);
                     } else {
                         $str_value = strval($value);
                         $size += strlen($str_value);
diff --git a/php/tests/EncodeDecodeTest.php b/php/tests/EncodeDecodeTest.php
index edcc025..65ac654 100644
--- a/php/tests/EncodeDecodeTest.php
+++ b/php/tests/EncodeDecodeTest.php
@@ -20,6 +20,10 @@
 use Foo\TestLargeFieldNumber;
 use Foo\TestMessage;
 use Foo\TestMessage\Sub;
+use Custom_enum_json\Knight;
+use Custom_enum_json\Knight\Shield;
+use Custom_enum_json\Armor;
+use Google\Protobuf\DescriptorPool;
 use Foo\TestPackedMessage;
 use Foo\TestRandomFieldOrder;
 use Foo\TestUnpackedMessage;
@@ -2155,4 +2159,289 @@
         $this->assertEquals(42, $m2->getOptionalMessage()->getA());
         $this->assertEquals(33 * 1024 * 1024, strlen($m2->getOptionalString()));
     }
+
+    public function testSerializeGreatHelm() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_GREAT_HELM);
+        $this->assertEquals(
+            '{"armor":"gr8 helm"}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseGreatHelm() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"gr8 helm"}');
+        $this->assertEquals(Armor::ARMOR_GREAT_HELM, $message->getArmor());
+    }
+
+    public function testParseGorgetDefault() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"ARMOR_GORGET"}');
+        $this->assertEquals(Armor::ARMOR_GORGET, $message->getArmor());
+    }
+
+    public function testParseGorgetNumber() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":2}');
+        $this->assertEquals(Armor::ARMOR_GORGET, $message->getArmor());
+    }
+
+    public function testSerializeGorget() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_GORGET);
+        $this->assertEquals(
+            '{"armor":"ARMOR_GORGET"}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testSerializeGauntlet() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_GAUNTLET);
+        $this->assertEquals(
+            '{"armor":"a\"b"}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseGauntlet() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"a\"b"}');
+        $this->assertEquals(Armor::ARMOR_GAUNTLET, $message->getArmor());
+    }
+
+    public function testParseGauntletRawName() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"ARMOR_GAUNTLET"}');
+        $this->assertEquals(Armor::ARMOR_GAUNTLET, $message->getArmor());
+    }
+
+    public function testSerializePlate() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_PLATE);
+        $this->assertEquals(
+            '{"armor":"\"plate\""}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParsePlate() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"\"plate\""}');
+        $this->assertEquals(Armor::ARMOR_PLATE, $message->getArmor());
+    }
+
+    public function testSerializeCoif() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_COIF);
+        $this->assertEquals(
+            '{"armor":""}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseCoif() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":""}');
+        $this->assertEquals(Armor::ARMOR_COIF, $message->getArmor());
+    }
+
+    public function testSerializePauldron() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_PAULDRON);
+        $this->assertEquals(
+            '{"armor":"p\\taul\\ndron"}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParsePauldron() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"p\\taul\\ndron"}');
+        $this->assertEquals(Armor::ARMOR_PAULDRON, $message->getArmor());
+    }
+
+    public function testSerializeSabaton() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_SABATON);
+        $this->assertEquals(
+            '{"armor":"sabaton"}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseSabaton() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"sabaton"}');
+        $this->assertEquals(Armor::ARMOR_SABATON, $message->getArmor());
+    }
+
+    public function testSerializeSolleret() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_SOLLERET);
+        $this->assertEquals(
+            '{"armor":"sabaton"}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseSolleret() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"sabaton"}');
+        $this->assertEquals(Armor::ARMOR_SOLLERET, $message->getArmor());
+    }
+
+    public function testSerializeHachiMaiDo() {
+        $message = new Knight();
+        $message->setArmor(Armor::ARMOR_HACHI_MAI_DO);
+        $this->assertEquals(
+            '{"armor":"8"}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseHachiMaiDo() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":"8"}');
+        $this->assertEquals(Armor::ARMOR_HACHI_MAI_DO, $message->getArmor());
+    }
+
+    public function testParseHachiMaiDoNumber() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor":8}');
+        $this->assertEquals(Armor::ARMOR_HACHI_MAI_DO, $message->getArmor());
+    }
+
+    public function testCaseSensitiveGauntletParsingFails() {
+        $message = new Knight();
+        $this->expectException(Exception::class);
+        $message->mergeFromJsonString('{"armor":"A\"b"}');
+    }
+
+    public function testCaseSensitiveRawNameParsingFails() {
+        $message = new Knight();
+        $this->expectException(Exception::class);
+        $message->mergeFromJsonString('{"armor":"armor_GAUNtlet"}');
+    }
+
+    public function testSerializeRepeatedEnum() {
+        $message = new Knight();
+        $message->setArmors([Armor::ARMOR_GREAT_HELM, Armor::ARMOR_GORGET]);
+        $this->assertEquals(
+            '{"armors":["gr8 helm","ARMOR_GORGET"]}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseRepeatedEnumStrict() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armors":["gr8 helm","ARMOR_GORGET"]}', false);
+        $this->assertCount(2, $message->getArmors());
+        $this->assertEquals(Armor::ARMOR_GREAT_HELM, $message->getArmors()[0]);
+        $this->assertEquals(Armor::ARMOR_GORGET, $message->getArmors()[1]);
+    }
+
+    public function testParseRepeatedEnumStrictUnknownFails() {
+        $message = new Knight();
+        $this->expectException(Exception::class);
+        $message->mergeFromJsonString('{"armors":["gr8 helm","UNKNOWN_ARMOR"]}', false);
+    }
+
+    public function testParseRepeatedEnumLenient() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armors":["gr8 helm","ARMOR_GORGET"]}', true);
+        $this->assertCount(2, $message->getArmors());
+        $this->assertEquals(Armor::ARMOR_GREAT_HELM, $message->getArmors()[0]);
+        $this->assertEquals(Armor::ARMOR_GORGET, $message->getArmors()[1]);
+    }
+
+    public function testParseRepeatedEnumLenientIgnoresUnknown() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armors":["gr8 helm","UNKNOWN_ARMOR","ARMOR_GORGET"]}', true);
+        $this->assertCount(2, $message->getArmors());
+        $this->assertEquals(Armor::ARMOR_GREAT_HELM, $message->getArmors()[0]);
+        $this->assertEquals(Armor::ARMOR_GORGET, $message->getArmors()[1]);
+    }
+
+    public function testSerializeMapEnum() {
+        $message = new Knight();
+        $message->setArmorMap(['head' => Armor::ARMOR_GREAT_HELM]);
+        $this->assertEquals(
+            '{"armorMap":{"head":"gr8 helm"}}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseMapEnumStrict() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor_map":{"head":"gr8 helm"}}', false);
+        $this->assertEquals(Armor::ARMOR_GREAT_HELM, $message->getArmorMap()['head']);
+    }
+
+    public function testParseMapEnumStrictUnknownFails() {
+        $message = new Knight();
+        $this->expectException(Exception::class);
+        $message->mergeFromJsonString('{"armor_map":{"head":"UNKNOWN_ARMOR"}}', false);
+    }
+
+    public function testParseMapEnumLenient() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor_map":{"head":"gr8 helm"}}', true);
+        $this->assertCount(1, $message->getArmorMap());
+        $this->assertEquals(Armor::ARMOR_GREAT_HELM, $message->getArmorMap()['head']);
+    }
+
+    public function testParseMapEnumLenientIgnoresUnknown() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"armor_map":{"head":"gr8 helm","unknown_key":"UNKNOWN_ARMOR"}}', true);
+        $this->assertCount(1, $message->getArmorMap());
+        $this->assertEquals(Armor::ARMOR_GREAT_HELM, $message->getArmorMap()['head']);
+    }
+
+    public function testSerializeNestedEnumShield() {
+        $message = new Knight();
+        $message->setShield(Shield::SHIELD_BUCKLER);
+        $this->assertEquals(
+            '{"shield":"buckler-custom"}',
+            $message->serializeToJsonString()
+        );
+    }
+
+    public function testParseNestedEnumShieldCustomName() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"shield":"buckler-custom"}');
+        $this->assertEquals(Shield::SHIELD_BUCKLER, $message->getShield());
+    }
+
+    public function testParseNestedEnumShieldRawName() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"shield":"SHIELD_BUCKLER"}');
+        $this->assertEquals(Shield::SHIELD_BUCKLER, $message->getShield());
+    }
+
+    public function testParseNestedEnumShieldNumber() {
+        $message = new Knight();
+        $message->mergeFromJsonString('{"shield":1}');
+        $this->assertEquals(Shield::SHIELD_BUCKLER, $message->getShield());
+    }
+
+    public function testEnumDescriptorReflection() {
+        new Knight();
+        $pool = DescriptorPool::getGeneratedPool();
+
+        $shieldDesc = $pool->getEnumDescriptorByClassName(Shield::class);
+        $this->assertInstanceOf('\Google\Protobuf\EnumDescriptor', $shieldDesc);
+        $this->assertSame(2, $shieldDesc->getValueCount());
+
+        $val0 = $shieldDesc->getValue(0);
+        $this->assertInstanceOf('\Google\Protobuf\EnumValueDescriptor', $val0);
+        $this->assertSame('SHIELD_UNKNOWN', $val0->getName());
+        $this->assertSame(0, $val0->getNumber());
+
+        $val1 = $shieldDesc->getValue(1);
+        $this->assertInstanceOf('\Google\Protobuf\EnumValueDescriptor', $val1);
+        $this->assertSame('SHIELD_BUCKLER', $val1->getName());
+        $this->assertSame(1, $val1->getNumber());
+    }
 }
diff --git a/php/tests/proto/test_custom_enum_json.proto b/php/tests/proto/test_custom_enum_json.proto
new file mode 100644
index 0000000..f9e9704
--- /dev/null
+++ b/php/tests/proto/test_custom_enum_json.proto
@@ -0,0 +1,39 @@
+// Protocol Buffers - Google's data interchange format
+// Copyright 2026 Google LLC.  All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+edition = "2026";
+
+package custom_enum_json;
+
+import "google/protobuf/json_options.proto";
+
+enum Armor {
+  option allow_alias = true;
+
+  ARMOR_UNKNOWN = 0;
+  ARMOR_GREAT_HELM = 1 [(pb.enumvalue.json).string = "gr8 helm"];
+  ARMOR_GORGET = 2;
+  ARMOR_GAUNTLET = 3 [(pb.enumvalue.json).string = "a\"b"];
+  ARMOR_PLATE = 4 [(pb.enumvalue.json).string = "\"plate\""];
+  ARMOR_COIF = 5 [(pb.enumvalue.json).string = ""];
+  ARMOR_PAULDRON = 6 [(pb.enumvalue.json).string = "p\taul\ndron"];
+  ARMOR_SABATON = 7 [(pb.enumvalue.json).string = "sabaton"];
+  ARMOR_SOLLERET = 7 [(pb.enumvalue.json).string = "sabaton"];
+  ARMOR_HACHI_MAI_DO = 8 [(pb.enumvalue.json).string = "8"];
+}
+
+message Knight {
+  Armor armor = 1;
+  repeated Armor armors = 2;
+  map<string, Armor> armor_map = 3;
+
+  enum Shield {
+    SHIELD_UNKNOWN = 0;
+    SHIELD_BUCKLER = 1 [(pb.enumvalue.json).string = "buckler-custom"];
+  }
+  Shield shield = 4;
+}
diff --git a/src/google/protobuf/compiler/php/BUILD b/src/google/protobuf/compiler/php/BUILD
index 762949a..c39ba4c 100644
--- a/src/google/protobuf/compiler/php/BUILD
+++ b/src/google/protobuf/compiler/php/BUILD
@@ -77,6 +77,7 @@
         ":php_generator",
         "//:protobuf",
         "//src/google/protobuf/compiler:command_line_interface_tester",
+        "@abseil-cpp//absl/strings",
         "@googletest//:gtest",
         "@googletest//:gtest_main",
     ],
diff --git a/src/google/protobuf/compiler/php/generator_unittest.cc b/src/google/protobuf/compiler/php/generator_unittest.cc
index 219d4d4..ff62eca 100644
--- a/src/google/protobuf/compiler/php/generator_unittest.cc
+++ b/src/google/protobuf/compiler/php/generator_unittest.cc
@@ -9,6 +9,7 @@
 
 #include "google/protobuf/descriptor.pb.h"
 #include <gtest/gtest.h>
+#include "absl/strings/string_view.h"
 #include "google/protobuf/compiler/command_line_interface_tester.h"
 #include "google/protobuf/compiler/php/php_generator.h"
 
@@ -197,20 +198,36 @@
   ExpectNoErrors();
 }
 
-// TODO Remove this test once full Edition 2026 support is in PHP
-TEST_F(PhpGeneratorTest, Edition2026Fails) {
-  CreateTempFile("foo.proto",
-                 R"schema(
+TEST_F(PhpGeneratorTest, CustomEnumNames) {
+  CreateTempFile("google/protobuf/json_enumvalue_options.proto", R"schema(
+    edition = "2024";
+    package pb.enumvalue;
+    import "google/protobuf/descriptor.proto";
+    message JsonEnumValueOptions {
+      string string = 1;
+    }
+    extend google.protobuf.EnumValueOptions {
+      JsonEnumValueOptions json = 998;
+    }
+  )schema");
+
+  CreateTempFile("foo.proto", R"schema(
     edition = "2026";
-    enum Foo {
-      BAR = 0;
-    })schema");
+    package foo;
+    import "google/protobuf/json_enumvalue_options.proto";
+    enum MyEnum {
+      MY_ENUM_UNKNOWN = 0;
+      MY_ENUM_BAR = 1 [(pb.enumvalue.json).string = "custom_bar"];
+      MY_ENUM_BAZ = 2;
+    }
+  )schema");
 
   RunProtoc(
       "protocol_compiler --proto_path=$tmpdir --php_out=$tmpdir foo.proto");
 
-  ExpectErrorSubstring(
-      "PHP does not yet fully support Edition 2026, but is coming soon.");
+  ExpectNoErrors();
+  ExpectFileContentContainsSubstring(
+      "GPBMetadata/Foo.php", "\"foo.MyEnum.MY_ENUM_BAR\" => \"custom_bar\"");
 }
 
 }  // namespace
diff --git a/src/google/protobuf/compiler/php/php_generator.cc b/src/google/protobuf/compiler/php/php_generator.cc
index b2954eb..46fbaf8 100644
--- a/src/google/protobuf/compiler/php/php_generator.cc
+++ b/src/google/protobuf/compiler/php/php_generator.cc
@@ -10,6 +10,7 @@
 #include <algorithm>
 #include <sstream>
 #include <string>
+#include <utility>
 #include <vector>
 
 #include "absl/container/flat_hash_map.h"
@@ -29,9 +30,11 @@
 #include "google/protobuf/descriptor.pb.h"
 #include "google/protobuf/io/printer.h"
 #include "google/protobuf/io/zero_copy_stream.h"
+#include "google/protobuf/json_enumvalue_options.pb.h"
 
 constexpr absl::string_view kDescriptorFile =
     "google/protobuf/descriptor.proto";
+
 constexpr absl::string_view kEmptyFile = "google/protobuf/empty.proto";
 constexpr absl::string_view kEmptyMetadataFile =
     "GPBMetadata/Google/Protobuf/GPBEmpty.php";
@@ -935,10 +938,18 @@
 
   for (int i = 0; i < en->value_count(); i++) {
     const EnumValueDescriptor* value = en->value(i);
+    std::string custom_json_name;
+    if (value->options().HasExtension(pb::enumvalue::json)) {
+      absl::string_view json_name =
+          value->options().GetExtension(pb::enumvalue::json).string();
+      custom_json_name =
+          absl::StrCat(", \"", BinaryToPhpString(std::string(json_name)), "\"");
+    }
     printer->Print(
-        "->value(\"^name^\", ^number^)\n", "name",
+        "->value(\"^name^\", ^number^^custom_json_name^)\n", "name",
         absl::StrCat(ConstantNamePrefix(value->name()), value->name()),
-        "number", IntToString(value->number()));
+        "number", IntToString(value->number()), "custom_json_name",
+        custom_json_name);
   }
   printer->Print("->finalizeToPool();\n\n");
   Outdent(printer);
@@ -1019,6 +1030,42 @@
   }
 }
 
+void CollectCustomEnumJsonNames(
+    const EnumDescriptor* en,
+    std::vector<std::pair<std::string, std::string>>* custom_names) {
+  for (int i = 0; i < en->value_count(); ++i) {
+    const EnumValueDescriptor* value = en->value(i);
+    if (value->options().HasExtension(pb::enumvalue::json)) {
+      std::string fqn = absl::StrCat(en->full_name(), ".", value->name());
+      std::string custom_name(
+          value->options().GetExtension(pb::enumvalue::json).string());
+      custom_names->push_back({std::move(fqn), std::move(custom_name)});
+    }
+  }
+}
+
+void CollectCustomEnumJsonNames(
+    const Descriptor* message,
+    std::vector<std::pair<std::string, std::string>>* custom_names) {
+  for (int i = 0; i < message->enum_type_count(); ++i) {
+    CollectCustomEnumJsonNames(message->enum_type(i), custom_names);
+  }
+  for (int i = 0; i < message->nested_type_count(); ++i) {
+    CollectCustomEnumJsonNames(message->nested_type(i), custom_names);
+  }
+}
+
+void CollectCustomEnumJsonNames(
+    const FileDescriptor* file,
+    std::vector<std::pair<std::string, std::string>>* custom_names) {
+  for (int i = 0; i < file->enum_type_count(); ++i) {
+    CollectCustomEnumJsonNames(file->enum_type(i), custom_names);
+  }
+  for (int i = 0; i < file->message_type_count(); ++i) {
+    CollectCustomEnumJsonNames(file->message_type(i), custom_names);
+  }
+}
+
 void GenerateAddFileToPool(const FileDescriptor* file, const Options& options,
                            io::Printer* printer) {
   printer->Print(
@@ -1095,11 +1142,25 @@
       std::string files_data;
       ABSL_CHECK(files.SerializeToString(&files_data));
 
+      std::vector<std::pair<std::string, std::string>> custom_json_names;
+      CollectCustomEnumJsonNames(file, &custom_json_names);
+
       printer->Print("$pool->internalAddGeneratedFile(\n");
       Indent(printer);
       printer->Print("\"^data^\"\n", "data", BinaryToPhpString(files_data));
       Outdent(printer);
-      printer->Print(", true);\n\n");
+      if (custom_json_names.empty()) {
+        printer->Print(", true);\n\n");
+      } else {
+        printer->Print(", true, [\n");
+        Indent(printer);
+        for (const auto& [fqn, custom_name] : custom_json_names) {
+          printer->Print("\"^fqn^\" => \"^custom_name^\",\n", "fqn", fqn,
+                         "custom_name", BinaryToPhpString(custom_name));
+        }
+        Outdent(printer);
+        printer->Print("]);\n\n");
+      }
     }
     printer->Print("static::$is_initialized = true;\n");
   }
@@ -1175,6 +1236,7 @@
   absl::flat_hash_map<const FileDescriptor*, int> dependency_count;
   absl::flat_hash_set<const FileDescriptor*> nodes_without_dependency;
   FileDescriptorSet sorted_file_set;
+  std::vector<const FileDescriptor*> aggregated_files;
 
   AnalyzeDependencyForFile(file, &nodes_without_dependency, &deps,
                            &dependency_count);
@@ -1194,6 +1256,7 @@
     bool needs_aggregate = NeedsUnwrapping(file_node, options);
 
     if (needs_aggregate) {
+      aggregated_files.push_back(file_node);
       auto file_proto = sorted_file_set.add_file();
       *file_proto = StripSourceRetentionOptions(*file_node);
 
@@ -1234,11 +1297,27 @@
   std::string files_data;
   ABSL_CHECK(sorted_file_set.SerializeToString(&files_data));
 
+  std::vector<std::pair<std::string, std::string>> custom_json_names;
+  for (const auto* file_node : aggregated_files) {
+    CollectCustomEnumJsonNames(file_node, &custom_json_names);
+  }
+
   printer->Print("$pool->internalAddGeneratedFile(\n");
   Indent(printer);
   printer->Print("\"^data^\"\n", "data", BinaryToPhpString(files_data));
   Outdent(printer);
-  printer->Print(", true);\n");
+  if (custom_json_names.empty()) {
+    printer->Print(", true);\n");
+  } else {
+    printer->Print(", true, [\n");
+    Indent(printer);
+    for (const auto& [fqn, custom_name] : custom_json_names) {
+      printer->Print("\"^fqn^\" => \"^custom_name^\",\n", "fqn", fqn,
+                     "custom_name", BinaryToPhpString(custom_name));
+    }
+    Outdent(printer);
+    printer->Print("]);\n");
+  }
 
   printer->Print("static::$is_initialized = true;\n");
 }
@@ -2252,12 +2331,6 @@
         "Can only generate PHP code for google/protobuf/descriptor.proto.\n";
     return false;
   }
-  // TODO Remove once full Edition 2026 support is in PHP
-  if (GetEdition(*file) >= Edition::EDITION_2026) {
-    *error =
-        "PHP does not yet fully support Edition 2026, but is coming soon.\n";
-    return false;
-  }
 
   return GenerateFile(file, options, generator_context, error);
 }