Search code examples
phpdoctrine-ormphp-8php-attributes

Doctrine ORM 2.9 use both AnnotationDriver and AttributeDriver to parse entity metadata


Recently we upgraded our applications to PHP8.

Since PHP8 introduced attributes and doctrine/orm supports them as of version 2.9 it seemed like a good idea to utilize this feature to incrementally (ie. not all entities at once) update entity metadata to the attributes' format.

In order to do so I need to somehow register both Doctrine\ORM\Mapping\Driver\AnnotationDriver and Doctrine\ORM\Mapping\Driver\AttributeDriver to parse the metadata.

The tricky part is to register both parsers for a set of entities decorated either using annotations or attributes. From the point of Doctrine\ORM\Configuration it seems what I need is not possible.

Am I correct (in assumption this cannot be reasonably achieved) or could this be done in some not-very-hackish way?


Solution

  • Doctrine by itself doesn't offer this possibility. But we can implement a custom mapping driver to make this happen.

    The actual implementation could look like this:

    <?php                                                                           
                                                                                    
    namespace Utils\Doctrine;                                                    
                                                                                    
    use Doctrine\ORM\Mapping\Driver\AnnotationDriver;                               
    use Doctrine\ORM\Mapping\Driver\AttributeDriver;                                
    use Doctrine\ORM\Mapping\MappingException;                                      
    use Doctrine\Persistence\Mapping\ClassMetadata;                                 
    use Doctrine\Persistence\Mapping\Driver\AnnotationDriver as AbstractAnnotationDriver;
                                                                                    
    class HybridMappingDriver extends AbstractAnnotationDriver                      
    {                                                                               
        public function __construct(                                                
            private AnnotationDriver $annotationDriver,                                
            private AttributeDriver $attributeDriver,                                  
        ) {                                                                            
        }                                                                              
                                                                                    
        public function loadMetadataForClass($className, ClassMetadata $metadata): void
        {                                                                           
            try {                                                                      
                $this->attributeDriver->loadMetadataForClass($className, $metadata);
                return;                                                             
            } catch (MappingException $me) {                                        
                // Class X is not a valid entity, so try the other driver            
                if (!preg_match('/^Class(.)*$/', $me->getMessage())) {// meh           
                    throw $me;                                                         
                }                                                                      
            }                                                                       
            $this->annotationDriver->loadMetadataForClass($className, $metadata);   
        }                                                                            
                                                                                     
        public function isTransient($className): bool                                     
        {                                                                           
            return $this->attributeDriver->isTransient($className)                     
                || $this->annotationDriver->isTransient($className);                   
        }                                                                              
    }
    

    In a nutshell:

    • the driver tries to use AttributeDriver first, then fallbacks to the AnnotationDriver in case the class under inspection is not evaluated as a valid entity
    • in order to comply with Doctrine\Persistence\Mapping\Driver\MappingDriver interface after extending Doctrine\Persistence\Mapping\Driver\AnnotationDriver class only 2 methods have to be implemented
    • as it can be seen in the example implementation both methods regard both metadata mapping drivers
    • distinguishing between various kinds of MappingExceptions by parsing the message is not elegant at all, but there is no better attribute to distinguish by; having different exception subtypes or some unique code per mapping error case would help a lot to differentiate between individual causes of mapping errors

    The HybridMappingDriver can be hooked up in an EntityManagerFactory like this:

    <?php
    
    namespace App\Services\Doctrine;
    
    use Doctrine\ORM\Tools\Setup;
    use Doctrine\ORM\EntityManager;
    use Doctrine\Common\Annotations\AnnotationRegistry;
    use Doctrine\Common\Proxy\AbstractProxyFactory as APF;
    use Doctrine\Persistence\Mapping\Driver\MappingDriver;
    use Utils\Doctrine\NullCache;
    
    class EntityManagerFactory
    {
        public static function create(
            array $params,
            MappingDriver $mappingDriver,
            bool $devMode,
        ): EntityManager {
            AnnotationRegistry::registerLoader('class_exists');
            $config = Setup::createConfiguration(
                $devMode,
                $params['proxy_dir'],
                new NullCache(), // must be an instance of Doctrine\Common\Cache\Cache
            );
            $config->setMetadataDriverImpl($mappingDriver); // <= this is the actual hook-up
            if (!$devMode) {
                $config->setAutoGenerateProxyClasses(APF::AUTOGENERATE_FILE_NOT_EXISTS);
            }
    
            return EntityManager::create($params['database'], $config);
        }
    }