Dynamic mapping in Doctrine and Symfony: How to extend entities
Charles Pourcel8 min read
When developing with Symfony2, you may one day want to create an entity that will be used by many other entities in your application, using the exact same relation every time.
Let’s say you create an UploadedDocument entity and you know from the start that you will have to manage uploads in different contexts in the same application (attachments to a blog article, attachments to a user message, etc.).
Sure you could manage these contexts manually and copy/paste your ORM definitions everywhere. But that would be wrong… You should consider ORM mapping information as your code: “DRY” and maintainable.
What if you have five, ten, fifteen different contexts in your huge application? What if you are ten different developers on this project? Each with their own way to define the mapping (forgetting here or there to define part of the mapping, making it invalid).
Convinced? Let’s dive into this upload example to explain the basic idea…
First a single context example
The basic UploadedDocument entity
Here is a very very basic entity we will consider as our UploadedDocument entity.
Note : As this article is not focused on the upload process you will not find details on how to manage cleanly a resource upload (for this, refer to the corresponding official documentation resource):
// src/Acme/DemoBundle/Entity/UploadedDocument.php
class UploadedDocument
{
@var integer
protected $id;
@var string
protected $name;
@var string
protected $path;
//Add corresponding getters and setters
}
Nothing fancy as you can see, nonetheless do not forget the corresponding basic mapping (assuming here you use XML):
// src/Acme/DemoBundle/Resources/config/doctrine/UploadedDocument.orm.xml
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping
xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"
>
<entity name="Acme\\DemoBundle\\Entity\\UploadedDocument"
table="uploaded\_document"
>
<id name="id" type="integer" column="id">
<generator strategy="AUTO"/>
</id>
<field name="name" type="string" column="name" length="150"/>
<field name="path" type="string" column="path" length="255"/>
</entity>
</doctrine-mapping>
The BlogArticle entity
You want to attach documents to a blog article so let’s create the basic BlogArticle entity:
// src/Acme/DemoBundle/Entity/BlogArticle.php
class BlogArticle
{
@var integer
protected $id;
@var string
protected $title;
@var string
protected $content;
//Add corresponding getters and setters
}
And the corresponding mapping:
// src/Acme/DemoBundle/Resources/config/doctrine/BlogArticle.orm.xml
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping
xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"
>
<entity name="Acme\\DemoBundle\\Entity\\BlogArticle"
table="blog\_article"
>
<id name="id" type="integer" column="id">
<generator strategy="AUTO"/>
</id>
<field name="title" type="string" column="title" length="150" />
<field name="content" type="text" column="content" />
</entity>
</doctrine-mapping>
As you can see nothing actually relates the BlogArticle entity to the UploadedDocument entity yet. As explained above in this article you could, at this point, decide to write manually your mapping information in this BlogArticle.orm.xml file. The point of this article is to present you another way…
The Dynamic Mapping Event Subscriber
We can also define relations using an EventListener or (as shown here) an EventSubscriber:
// src/Acme/DemoBundle/EventListener/DynamicRelationSubscriber.php
class DynamicRelationSubscriber implements EventSubscriber
{
{@inheritDoc}
public function getSubscribedEvents()
{
return array(
Events::loadClassMetadata,
);
}
@param LoadClassMetadataEventArgs $eventArgs
public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs)
{
// the $metadata is the whole mapping info for this class
$metadata = $eventArgs->getClassMetadata();
if ($metadata->getName() != 'Acme\\DemoBundle\\Entity\\BlogArticle') {
return;
}
$namingStrategy = $eventArgs
->getEntityManager()
->getConfiguration()
->getNamingStrategy()
;
$metadata->mapManyToMany(array(
'targetEntity' => UploadedDocument::CLASS,
'fieldName' => 'uploadedDocuments',
'cascade' => array('persist'),
'joinTable' => array(
'name' => strtolower($namingStrategy->classToTableName($metadata->getName())) . '\_document',
'joinColumns' => array(
array(
'name' => $namingStrategy->joinKeyColumnName($metadata->getName()),
'referencedColumnName' => $namingStrategy->referenceColumnName(),
'onDelete' => 'CASCADE',
'onUpdate' => 'CASCADE',
),
),
'inverseJoinColumns' => array(
array(
'name' => 'document\_id',
'referencedColumnName' => $namingStrategy->referenceColumnName(),
'onDelete' => 'CASCADE',
'onUpdate' => 'CASCADE',
),
)
)
));
}
}
Note : The ClassName::CLASS notation appeared in PHP 5.5. For previous PHP versions you could also create a constant in the subscriber with the full qualified namespace to the UploadedDocument Entity (AcmeDemoBundleEntityUploadedDocument) and use this constant instead.
Since a relation has been added to the BlogArticle, Symfony will expect that the corresponding setters and getters exist. This is a limitation of the dynamic mapping, you will still have to define them manually:
// src/Acme/DemoBundle/Entity/BlogArticle.php
class BlogArticle
{
protected $id;
protected $title;
protected $content;
@var ArrayCollection
protected $uploadedDocuments;
// \[...\]
public function addUploadedDocument(UploadedDocument $uploadedDocument)
{
$this->uploadedDocuments->add($uploadedDocument);
return $this;
}
public function removeUploadedDocument(UploadedDocument $uploadedDocument)
{
$this->uploadedDocuments->removeElement($uploadedDocument);
}
public function getUploadedDocuments()
{
return $this->uploadedDocuments;
}
public function setUploadedDocuments(ArrayCollection $uploadedDocuments)
{
$this->uploadedDocuments = $uploadedDocuments;
return $this;
}
}
Technical insight on how it’s actually working behind the scene
What is mapping and how you usually modify it
The Doctrine ORM Mapping is like a bridge connecting two shores:
- Your entities, your object model on one side
- Your database tables, your database model on the other side
It allows Doctrine to understand how information stored in your entities are actually persisted in your database, how the different database tables are related and reciprocally, how information stored in the database will be fetched and hydrated into your entities.
As I said, there are usually four ways of manipulating Doctrine ORM mapping: YamL, XML, PHP and Annotations. This one is a complement, compatible with all the other ones.
You already have met dynamic mapping
Most of the Symfony2 developers have used at least once Gedmo’s Doctrine extensions so I am quite sure you used dynamic mapping without even knowing it.
In fact Gedmo’s behaviour is based on this. What we usually call ORM mapping information are in fact metadata associated to the entity class. Gedmo plugs into the metadata you defined for an entity and extends them.
When is doctrine mapping generated?
These metadata are loaded by the DoctrineORMMappingClassMetadataFactory which is created by your EntityManager at instanciation time and which itself exposes a public method to load these, entity by entity (getClassMetadata($className)). In fact, most of the time you will end up with all the metadata loaded for all entities shortly after the EntityManager has been created.
How to dynamically modify it?
When the metadata of a class are loaded, the ClassMetadataFactory checks if something is listening on the Events::loadClassMetadata event, and if yes, triggers it with a LoadClassMetadataEventArgs object which gives access to the current class metadata.
You can thus easily create a listener or a subscriber mapped on this event and extend all (or a subset of) your entities by adding the metadata you want using the methods exposed in the Doctrine PHP mapping reference.
But let’s go back to our example and improve things a little bit.
Useful refactoring of common entity behaviour
Warning
This solution is only available since PHP 5.4.0 as Trait have been implemented from this version on.
Since the uploaded Documents property and the corresponding getters and setters will be common to all the entities requiring a many-to-many relation with UploadedDocument, we could refactor this into a generic trait that we will use in the BlogArticle entity:
// src/Acme/DemoBundle/Entity/HasUploadedDocumentTrait
trait HasUploadedDocumentTrait
{
@var ArrayCollection
protected $uploadedDocuments;
public function addUploadedDocument(UploadedDocument $uploadedDocument)
{
$this->uploadedDocuments->add($uploadedDocument);
return $this;
}
public function removeUploadedDocument(UploadedDocument $uploadedDocument)
{
$this->uploadedDocuments->removeElement($uploadedDocument);
}
public function getUploadedDocuments()
{
return $this->uploadedDocuments;
}
public function setUploadedDocuments(ArrayCollection $uploadedDocuments)
{
$this->uploadedDocuments = $uploadedDocuments;
return $this;
}
}
``
The BlogArticle should be modified accordingly:
```php
// src/Acme/DemoBundle/Entity/BlogArticle.php
class BlogArticle
{
protected $id;
protected $title;
protected $content;
use HasUploadedDocumentTrait;
// \[...\]
}
Despite the use of this generic trait, the subscriber will only add the relation to the BlogArticle entity. Until now it was ok, but since we also want to attach documents to user messages we will have to customize it further by introducing a useful interface.
Make it mutliple contexts compatible
We will use an interface to detect which entity should be dynamically related to the UploadedDocument entity. Here is the contract that each entity requiring uploads will have to implement (directly extracted from the Trait mentioned above):
// src/Acme/DemoBundle/Entity/HasUploadedDocumentInterface.php
interface HasUploadedDocumentInterface
{
public function addUploadedDocument(UploadedDocument $uploadedDocument);
public function removeUploadedDocument(UploadedDocument $uploadedDocument);
public function getUploadedDocuments();
public function setUploadedDocuments(ArrayCollection $uploadedDocuments);
}
Therefore we refactor BlogArticle and UserMessage to implement this interface:
// src/Acme/DemoBundle/Entity/BlogArticle.php
class BlogArticle implements HasUploadedDocumentInterface
{
// \[...\]
use HasUploadedDocumentTrait;
// \[...\]
}
// src/Acme/DemoBundle/Entity/UserMessage.php
class UserMessage implements HasUploadedDocumentInterface
{
protected $id;
// \[...\]
use HasUploadedDocumentTrait;
// \[...\]
}
Now we will modify the subscriber behaviour by using this interface to make the subscriber add the relation to any entity implementing it:
// src/Acme/DemoBundle/EventListener/DynamicRelationSubscriber.php
class DynamicRelationSubscriber implements EventSubscriber
{
const INTERFACE_FQNS = 'Acme\\DemoBundle\\Entity\\HasUploadedDocumentInterface';
// \[...\]
@param LoadClassMetadataEventArgs $eventArgs
public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs)
{
// the $metadata is the whole mapping info for this class
$metadata = $eventArgs->getClassMetadata();
if (!in\_array(self::INTERFACE\_FQNS, class\_implements($metadata->getName()))) {
return;
}
$namingStrategy = $eventArgs
->getEntityManager()
->getConfiguration()
->getNamingStrategy()
;
// \[...\]
}
}
Now the subscriber implementation is reusable and it would be easy to add uploads to a third context. It would require only the following modifications:
// src/Acme/DemoBundle/Entity/ThirdContext
class ThirdContext implements HasUploadedDocumentInterface
{
// \[...\]
use HasUploadedDocumentTrait;
// \[...\]
}
That’s it! That’s all the ThirdContext entity require to have now a relation with the uploaded document.
Bring these modifications to the database
Once ORM mapping information altered dynamically, Symfony knows immediately about the modifications you made but not your database. To push these, you will just have to run the doctrine:schema: update command or to generate and run the corresponding doctrine migration.
Conclusion
Dynamic Doctrine mapping has the same functionalities as any static way. This is not a completely new way of defining ORM metadata but only a complement to the existing ones using the PHP syntax.
It won’t (as explained above) automatically take care of altering the corresponding database structure but it can help you extend your entities and centralize common metadata manipulations (prefix some database tables name as shown in this doctrine documentation example) or alter metadata you statically set using reflection or whatever logic you want. I personally found it useful to set relations between cross bundle entities without bringing too many modifications to my own entities.