This bundle is highly inspired from KnpLabs/DoctrineBehaviors. Some behaviors have been modified because they didn't accomplish exactly what we wanted.
New behaviors have been added : Uploadable, Metadatable and Taggable.
The original behaviors have been wrapped in a Symfony2 bundle.
PHP 5.4 is required because we use traits.
For now, these behaviors are available :
- Translatable
- TranslatableEntityRepository
- Sluggable
- Timestampable
- Uploadable
- Metadatable
- Taggable
- Blameable
- SoftDeletable
You have to generate both Translatable and Translation entities. For example, Text and TextTranslation :
# Text.orm.yml
Unifik\SystemBundle\Entity\Text:
type: entity
fields:
id:
type: integer
id: true
generator:
strategy: AUTO
createdAt:
type: datetime
gedmo:
timestampable:
on: create
updatedAt:
type: datetime
gedmo:
timestampable:
on: update
# TextTranslation.orm.yml
Unifik\SystemBundle\Entity\TextTranslation:
type: entity
fields:
id:
type: integer
id: true
generator:
strategy: AUTO
text:
type: text
name:
type: string
length: 255
nullable: true
active:
type: boolean
nullable: true
In the Translatable entity, add a use
statement to include the Translatable
trait
<?php
namespace Unifik\SystemBundle\Entity;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
/**
* Text
*/
class Text
{
use UnifikORMBehaviors\Translatable\Translatable;
/**
* @var integer $id
*/
protected $id;
[...]
}
In the Translation entity, add a use
statement to include the Translation
trait
<?php
namespace Unifik\SystemBundle\Entity;
use Symfony\Component\Validator\ExecutionContextInterface;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
/**
* TextTranslation
*/
class TextTranslation
{
use UnifikORMBehaviors\Translatable\Translation;
/**
* @var integer $id
*/
protected $id;
[...]
}
You're done! The locale
field and the bidirectional relation are automatically registered to the entity's classMetadata with Doctrine Event Listeners with the following names :
// $translations property
$textTranslations = $text->getTranslations();
// $translatable property
$text = $textTranslation->getTranslatable();
// $locale property
$locale = $text->getLocale()
Translated entities are loaded with the current locale on a postLoad Doctrine Event. If you want to load an entity in a specific locale, you can use the "setCurrentLocale" method :
$text->setCurrentLocale('fr');
$name = $text->getName(); // Will return the 'fr' version of the $name property
You need to build a form for both the Translatable and the Translation entities, and embed a TranslationForm on the translation
property :
/**
* Text Type
*/
class TextType extends AbstractType
{
/**
* Build Form
*
* @param FormBuilderInterface $builder The builder
* @param array $options Form options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('translation', new TextTranslationType())
;
}
[...]
}
/**
* Text Translation Type
*/
class TextTranslationType extends AbstractType
{
/**
* Build Form
*
* @param FormBuilderInterface $builder The builder
* @param array $options Form options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('active', 'checkbox')
->add('name')
->add('text', 'textarea', array('label' => 'Text', 'attr' => array('class' => 'ckeditor')))
;
}
[...]
}
In twig, you can simply render the form with form_rest
or field by field :
<table class="fields" cellspacing="0">
{{ form_row(form.translation.active) }}
{{ form_row(form.translation.name) }}
{{ form_row(form.translation.text) }}
{{ form_rest(form) }}
</table>
This trait is related to the Translatable behavior.
It handles automatic LEFT/INNER JOIN on Translation tables to avoid additional queries to fetch the translation rows, when using the find
, findBy
, findOneBy
and findAll
methods.
To use this trait, you need to extend the Doctrine\ORM\EntityRepository
and implement the Symfony\Component\DependencyInjection\ContainerAwareInterface
, as follow :
<?php
namespace Unifik\SystemBundle\Entity;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
/**
* SectionRepository
*/
class SectionRepository extends EntityRepository implements ContainerAwareInterface
{
use UnifikORMBehaviors\Repository\TranslatableEntityRepository;
}
This behavior is pretty simple to implement with only two steps :
The trait will be used to add a slug
field to the entity's metadataClass and to configure the slug field.
You need to add a use
statement to include the sluggable
trait and define the getSluggableFields
method (declared as abstract in the trait) to configure the fields (1 or more) to slug :
<?php
namespace Unifik\SystemBundle\Entity;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
/**
* SectionTranslation
*/
class SectionTranslation
{
use UnifikORMBehaviors\Sluggable\Sluggable;
/**
* @var integer $id
*/
protected $id;
[...]
/**
* Get Sluggable Fields
*
* @return array
*/
public function getSluggableFields()
{
return array('name');
}
}
Other methods can be overloaded to configure the behavior :
getIsSlugUnique
: Determines whether the slug is unique or not. Default istrue
(It supports the translatable behavior by looking for a similar slug in the current locale only).getSlugDelimiter
: The slug delemiter. Default is-
.getRegenerateOnUpdate
: Determines if the slug should be regenerated when a sluggable field has been modified. Default istrue
. If set tofalse
, the slug will be regenerated only if the slug field is set toNULL
or an empty string.
There is no need to create a service is you wish to use the default behavior.
When the slug is configured to be unique (via the getIsSlugUnique
method in the entity/trait), a QueryBuilder is used to make a query on the entity's table to find a slug similar (in the same locale if it's a Translation entity) to the one generated by this behavior. While a slug is found, the slug will be appended by "-1", "-2", and so on.
Optionally, you can create a new service and override the default getSelectQueryBuilder
method to specify a different QueryBuilder. The QueryBuilder must find a slug similar to the entity's one, on other entities.
Simply use your own class for the service as follow :
services:
unifik_system.section_translation.sluggable.listener:
class: %unifik_system.section_translation.sluggable.listener.class%
tags:
- { name: doctrine.event_subscriber, type: sluggable, entity: Unifik\SystemBundle\Entity\SectionTranslation }
The class needs to extend the SluggableListener
abstract class.
Here is an example of a custom service. We try to find a similar slug only on entities having the same parent than the sluggable's one :
// SectionTranslationSluggableListener.php
<?php
namespace Unifik\SystemBundle\Lib;
use Unifik\DoctrineBehaviorsBundle\ORM\Sluggable\SluggableListener;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\QueryBuilder;
/**
* Class SectionTranslationSluggableListener
*/
class SectionTranslationSluggableListener extends SluggableListener
{
/**
* Returns the Select QueryBuilder that will check for a similar slug in the table
* The slug will be valid when the Query returns 0 rows.
*
* @param string $slug
* @param mixed $entity
* @param EntityManager $em
*
* @return QueryBuilder
*/
public function getSelectQueryBuilder($slug, $entity, EntityManager $em)
{
$translatable = $entity->getTranslatable();
$queryBuilder = $em->createQueryBuilder()
->select('DISTINCT(s.slug)')
->from('Unifik\SystemBundle\Entity\SectionTranslation', 's')
->innerJoin('s.translatable', 't')
->where('s.slug = :slug')
->andWhere('s.locale = :locale')
->setParameters([
'slug' => $slug,
'locale' => $entity->getLocale()
]);
// On update, look for other slug, not the current entity slug
if ($em->getUnitOfWork()->isScheduledForUpdate($entity)) {
$queryBuilder->andWhere('t.id <> :id')
->setParameter('id', $translatable->getId());
}
// Only look for slug on the same level
if ($translatable->getParent()) {
$queryBuilder->andWhere('t.parent = :parent')
->setParameter('parent', $translatable->getParent());
}
return $queryBuilder;
}
}
The uploadable behavior simplifies the way you handle file upload in Symfony2. A trait is used to configure the uploadable fields and you only have to add 2 properties :
- A property that will contain the filename and will be persisted
- A non-persisted property that will contain the submitted file as an
\Symfony\Component\HttpFoundation\File\UploadedFile
entity
You can optionally define what is your upload root (absolute) and web (relative to /web) folder by adding these lines to the config.yml
file :
unifik_doctrine_behaviors:
uploadable:
upload_root_dir: ../web/uploads
upload_web_dir: /uploads
The ../web/uploads
and /uploads
paths are the default values. If you wish to use the default paths, you don't have to add anything to config.yml
.
You will be able to specify a different subfolder of upload_root_dir
for each uploadable field in your entity, we'll see how later on.
First, you will have to add a non-persisted property and add a use
statement to include the Uploadable trait.
This trait contains the getUploadableFields
abstract method that you will need to define in your entity.
This method returns a key => value
array of the list of uploadable fields (key) with their respective upload directory (value).
You can add as many uploadable fields as you wish. In this example, we'll add two uploadable fields, as follow :
<?php
namespace Unifik\SystemBundle\Entity;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
/**
* Section
*/
class Section
{
use UnifikORMBehaviors\Uploadable\Uploadable;
/**
* @var integer
*/
private $id;
/**
* @var UploadedFile
*/
private $image;
/**
* @var UploadedFile
*/
private $otherImage;
/**
* Get the list of uploabable fields and their respective upload directory in a key => value array format.
*
* @return array
*/
public function getUploadableFields()
{
return [
'image' => 'images',
'otherImage' => 'autres_images'
];
}
/**
* @param UploadedFile $image
*/
public function setImage($image)
{
$this->setUploadedFile($image, 'image');
}
/**
* @return UploadedFile
*/
public function getImage()
{
return $this->image;
}
/**
* @param UploadedFile $otherImage
*/
public function setOtherImage($otherImage)
{
$this->setUploadedFile($otherImage, 'otherImage');
}
/**
* @return UploadedFile
*/
public function getOtherImage()
{
return $this->otherImage;
}
[...]
}
Note: The UploadedFile
properties setters will have to call the trait method setUploadedFile
with 2 arguments, the UploadedFile
instance and the name of the field.
This method will handle the file naming and the file deleting in case of a file replacement.
Next, you'll have to add a persisted field for each uploadable field to your entity's schema.
The name of each persisted property will be the name of the non-persisted field suffixed by "Path".
In this example, for $image
we'll have $imagePath
and for $otherImage
, we'll have $otherImagePath
:
# Section.orm.yml
Unifik\SystemBundle\Entity\Section:
type: entity
fields:
id:
type: integer
id: true
generator:
strategy: AUTO
imagePath:
type: string
length: 255
nullable: true
otherImagePath:
type: string
length: 255
nullable: true
Generate the getters and the setters :
// Section.php
[...]
/**
* @param string $imagePath
*/
public function setImagePath($imagePath)
{
$this->imagePath = $imagePath;
}
/**
* @return string
*/
public function getImagePath()
{
return $this->imagePath;
}
/**
* @param string $otherImagePath
*/
public function setOtherImagePath($otherImagePath)
{
$this->otherImagePath = $otherImagePath;
}
/**
* @return string
*/
public function getOtherImagePath()
{
return $this->otherImagePath;
}
[...]
Other methods can be overloaded to configure the behavior :
getNamingStrategy
: Determines the naming strategy to use when renaming files with the alphanumeric naming strategy. Available choices arealphanumeric
,random
andnone
. See the phpdoc for a detailed description. Default isalphanumeric
.getAlphanumericDelimiter
: The delemiter when using the alphanumeric naming strategy. Default is-
.getIsUnique
: Determines whether the filename should be unique or not. Default istrue
. Iftrue
, the trait will generate a unique filename by appending "-1", "-2" and so on to the filename. If set tofalse
and the uploaded file name already exists on the disk, it will be overwrited.
Simply add a new file
field to your form type and you're done :
<?php
namespace Unifik\SystemBundle\Form\Backend;
/**
* Section Type
*/
class SectionType extends AbstractType
{
/**
* Build Form
*
* @param FormBuilderInterface $builder The Builder
* @param array $options Array of options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('image', 'file')
->add('otherImage', 'file')
;
}
[...]
}
The upload process is handled by this listener.
When an entity is deleted or when a file is replaced, the files get automatically deleted from the server.
The timestampable behavior is the easiest one to use, it simply requires to include a Trait in your entity and you're done. The updatedAt and createdAt properties will automatically be added to your entity.
Only add the Timestampable trait to your entity :
<?php
namespace Unifik\SystemBundle\Entity;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
/**
* Section
*/
class Section
{
use UnifikORMBehaviors\Timestampable\Timestampable;
/**
* @var integer
*/
private $id;
[...]
}
The blameable behavior lets you track which User created, updated or deleted an entity. You can configure a User entity to link with the blameable entities, which means that these entities will have a many-to-one relation with the User entity. If you don't specify a User entity, the name of the current logged User will be used instead and will be saved as string.
To activate the blameable behavior, simply use the Trait in the entity you want to behave as blameable :
<?php
namespace Unifik\SystemBundle\Entity;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
/**
* Section
*/
class Section extends BaseEntity
{
use UnifikORMBehaviors\Blameable\Blameable;
/**
* @var integer
*/
private $id;
[...]
}
If you want to create a Many-to-One relation between your User entity and your blameable entities, you can configure the listener to manage automatically the association by setting the user_entity
parameter to a fully qualified namespace :
# config.yml
unifik_doctrine_behaviors:
blameable:
user_entity: Unifik\SystemBundle\Entity\User
SoftDeletable let's you soft-delete an entity, which means that the entity won't be deleted but a deletedAt property will be set with the current timestamp when the entity gets deleted.
To make an entity behave as soft-deletable, simply use the SoftDeletable trait as follow :
<?php
namespace Unifik\SystemBundle\Entity;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
/**
* Section
*/
class Section extends BaseEntity
{
use UnifikORMBehaviors\SoftDeletable\SoftDeletable;
/**
* @var integer
*/
private $id;
[...]
}
Here are some examples of use in a controller :
<?php
$section = new Section();
$em->persist($section);
$em->flush();
// Get id
$id = $em->getId();
// Now remove it
$em->remove($section);
// Hey, i'm still here:
$section = $em->getRepository('UnifikSystemBundle:Section')->findOneById($id);
// But i'm "deleted"
$section->isDeleted(); // === true
<?php
$section = new Section();
$em->persist($section);
$em->flush();
// I'll delete you tomorow
$section->setDeletedAt((new \DateTime())->modify('+1 day'));
// Ok, I'm here
$section->isDeleted(); // === false
/*
* 24 hours later...
*/
// Ok I'm deleted
$section->isDeleted(); // === true
The metadatable behavior lets you specify metadata information on an entity. Three metadata are currently supported : Title, Description and Keywords.
If blank, the metaTitle will be auto-generated using the __toString method of the entity.
To activate the metadatable behavior, simply use the Trait in the entity you want to behave as metadatable :
<?php
namespace Unifik\SystemBundle\Entity;
use Unifik\DoctrineBehaviorsBundle\Model as UnifikORMBehaviors;
/**
* Section
*/
class Section extends BaseEntity
{
use UnifikORMBehaviors\Metadatable\Metadatable;
/**
* @var integer
*/
private $id;
[...]
}
To add the fields to your form, simply extend the MetadatableType and call the parent::buildForm function in the buildForm function :
<?php
namespace Unifik\SystemBundle\Form\Backend;
use Symfony\Component\Form\FormBuilderInterface;
use Unifik\DoctrineBehaviorsBundle\Form\MetadatableType;
/**
* Section Translation Type
*/
class SectionTranslationType extends MetadatableType
{
/**
* Build Form
*
* @param FormBuilderInterface $builder The Builder
* @param array $options Array of options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('someOtherFields');
}
[...]
}
The taggable behavior lets you add tags to an entity. You can define if your entity uses global tags ou specific entity tags.
To activate the taggable behavior, simply use the Trait in the entity you want to behave as taggable.
The getResourceType()
method defines what type of entity this tag is related to. Optionnaly, you can override this function to return the string
that you want.
This traits has a $tags
property with it's getter/setter. The tags are lazy loaded, no queries are executed to the database until you call the getTags()
getter. :
/**
* @var ArrayCollection
*/
protected $tags;
/**
* @var \Closure
*/
protected $tagReference;
/**
* @var \DateTime
*/
protected $tagsUpdatedAt;
/**
* Get Tags
*
* @return ArrayCollection
*/
public function getTags()
{
// Lazy load the tags, only once
if (null !== $this->tagReference && null === $this->tags) {
$tagReference = $this->tagReference;
$this->tagReference = null; // Avoir circular references
$tagReference();
}
if (null === $this->tags) {
$this->tags = new ArrayCollection();
}
return $this->tags;
}
/**
* Set Tags
*
* @param ArrayCollection $tags
* @return Taggable
*/
public function setTags($tags)
{
$this->tags = $tags;
$this->setTagsUpdatedAt(new \DateTime());
}
/**
* Add Tag
*
* @param Tag $tag
* @return Taggable
*/
public function addTag($tag)
{
$this->tags->add($tag);
$this->setTagsUpdatedAt(new \DateTime());
return $this;
}
The TaggableType
form type has been created to manage the tags via the entity's form.
Two parameters are required by the TaggableType
:
resource_type
: The resource type. The best way is to use the getResourceType() method of the Taggable entity.locale
: The locale in which the Tags will be fetched/created.
There are also 3 optional parameters :
use_fcbkcomplete
: Use the Fcbkcomplete jQuery plugin. This allows you to create tags on the fly. (default:true
)allow_add
: Allows you to add tags directly in your entity's form. Theuse_fcbkcomplete
option must be set totrue
. (default:true
)use_global_tags
: Defines if this form is using global tags or specific entity tags. Global tags will be shared across all other entities using global tags. If set to false, tags will be shared with other entities using the same resourceType (set in thegetResourceType()
function). (default:true
)
There are different ways to add this field to your entity's form. The best way is to use the entity binded to the form on FormsEvents::POST_SET_DATA
event. Here are some examples on how to use the TaggableType :
<?php
namespace Unifik\SystemBundle\Form\Backend;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Unifik\DoctrineBehaviorsBundle\Form\MetadatableType;
/**
* Section Translation Type
*/
class SectionTranslationType extends MetadatableType
{
/**
* Build Form
*
* @param FormBuilderInterface $builder The Builder
* @param array $options Array of options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('active')
->add('name')
->add('slug')
;
$builder->addEventListener(FormEvents::POST_SET_DATA, function ($event) {
$form = $event->getForm();
$form->add('tags', 'taggable', [
'resource_type' => $event->getData()->getResourceType(),
'locale' => $event->getData()->getTranslatable()->getCurrentLocale() // Translatable entity
]);
});
}
[...]
}
<?php
namespace Unifik\SystemBundle\Form\Backend;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Unifik\DoctrineBehaviorsBundle\Form\MetadatableType;
/**
* Section Translation Type
*/
class SectionTranslationType extends MetadatableType
{
/**
* Build Form
*
* @param FormBuilderInterface $builder The Builder
* @param array $options Array of options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('active')
->add('name')
->add('slug')
;
$form->add('tags', 'taggable', [
'resource_type' => $options['data']->getResourceType(),
'locale' => $options['data']->getTranslatable()->getCurrentLocale() // Translatable entity
]);
}
[...]
}
<?php
namespace Unifik\SystemBundle\Form\Backend;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Unifik\DoctrineBehaviorsBundle\Form\MetadatableType;
/**
* Section Translation Type
*/
class SectionTranslationType extends MetadatableType
{
/**
* Build Form
*
* @param FormBuilderInterface $builder The Builder
* @param array $options Array of options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
parent::buildForm($builder, $options);
$builder
->add('active')
->add('name')
->add('slug')
;
$form->add('tags', 'taggable', [
'use_global_tags' => false,
'resource_type' => 'section',
'locale' => 'en'
]);
}
[...]
}
There is no code to add to your controller, everything is handled by Listeners on Doctrine events.
If you wish to use the Fcbkcomplete jQuery plugin, simply include the JS and CSS files in your page and use the included Twig Form Theme, as follow :
{# edit.html.twig #}
{% form_theme form with ['form_div_layout.html.twig', 'UnifikDoctrineBehaviorsBundle:Taggable:form_theme.html.twig' %}
<form>
{{ form_row(form.tags) }}
</form>
Optionnaly, you can manage tags in a controller using the TagManager
service.
<?php
// Get the TagManager service
$this->tagManager = $this->get('unifik_doctrine_behaviors.tag_manager');
// Define a resource type (set to null if you want to use global tags)
$resourceType = 'Unifik\BlogBundle\Entity\Article';
// Load or create a new tag
$tag = $this->tagManager->loadOrCreateTag('Smallville', $resourceType);
// Load or create a list of tags
$tagNames = $this->tagManager->splitTagNames('Clark Kent, Loïs Lane, Superman'));
$tags = $this->tagManager->loadOrCreateTags($tagNames, $resourceType);
// Add a tag on your taggable resource..
$this->tagManager->addTag($tag, $article);
// Add a list of tags on your taggable resource..
$this->tagManager->addTags($tags, $article);
// Remove a tog on your taggable resource..
$this->tagManager->remove($tag, $article);
// Save tagging..
// Note: $article must be saved in your database before (persist & flush)
$this->tagManager->saveTagging($article);
// Load tagging..
$this->tagManager->loadTagging($article);
// Replace all current tags..
$tags = $this->tagManager->loadOrCreateTags(array('Smallville', 'Superman'), $resourceType);
$this->tagManager->replaceTags($tags, $article);
The Tag entity has a repository class, with two particularly helpful methods:
<?php
$tagRepo = $em->getRepository('UnifikDoctrineBehaviorsBundle:Tag');
// Define a resource type (set to null if you want to use global tags)
$resourceType = 'Unifik\SystemBundle\Entity\Section';
// or
$resourceType = $taggableEntity->getResourceType();
// Find all article ids matching a particular query
$ids = $tagRepo->getResourceIdsForTag($resourceType, 'footag');
// Get the tags and count for all articles
$tags = $tagRepo->getTagsWithCountArray($resourceType);
foreach ($tags as $name => $count) {
echo sprintf('The tag "%s" matches "%s" articles', $name, $count);
}
// Get the related blog Article having common tags with a Section entity
// This method creates a QueryBuilder with a "resource" alias, which in this case is "Unifik\BlogBundle\Entity\Article"
$queryBuilder = $tagRepo->getResourcesByTagsQueryBuilder($section->getTags(), 'Unifik\BlogBundle\Entity\Article');
$queryBuilder->orderBy('resource.updatedAt', 'DESC');
$articles = $queryBuilder->getQuery()->getResult();