unifik/doctrine-behaviors-bundle
A Symfony bundle that adds reusable Doctrine behavior traits such as timestampable, blameable, sluggable, and soft-deletable to your entities, reducing boilerplate and keeping entity mappings clean and consistent across projects.
1. Install and register the bundle.
composer require unifik/doctrine-behaviors-bundle:~1.0
There is no stable, actively maintained release line here, so expect to pin either a tag or a specific dev-master commit. Register it in app/AppKernel.php:
public function registerBundles()
{
return array(
// ...
new Unifik\DoctrineBehaviorsBundle\UnifikDoctrineBehaviorsBundle(),
);
}
Most behaviors are wired automatically through Doctrine event subscribers registered by the bundle's DI extension, so typically no config.yml entry is required to get the first behavior running. Check DependencyInjection/Configuration.php in the bundle for the few behaviors (usually sluggable/translatable) that expose options.
2. Look here first.
Behavior/ (or Behaviors/) — one subdirectory per behavior, each containing the trait(s) you use in your entity. This is your API surface; read the trait before using it so you know which properties it declares.EventListener/ or EventSubscriber/ — the Doctrine hooks (prePersist, preUpdate, loadClassMetadata) that actually mutate your entities. This tells you when a field gets populated.Resources/config/ — service definitions, useful if you need to override or disable a subscriber.Tests/ (if present) — the fastest way to see a complete entity + mapping example.3. First use case: timestamps on an entity.
namespace Acme\BlogBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Unifik\DoctrineBehaviorsBundle\Behavior\Timestampable\TimestampableTrait;
/**
* @ORM\Entity
* @ORM\Table(name="post")
*/
class Post
{
use TimestampableTrait;
/** @ORM\Id @ORM\GeneratedValue @ORM\Column(type="integer") */
private $id;
/** @ORM\Column(type="string", length=255) */
private $title;
}
Then map the columns the trait declares. Depending on whether the trait ships annotated properties or not, either they are picked up by the annotation driver, or you add createdAt/updatedAt to your own mapping file:
# Acme/BlogBundle/Resources/config/doctrine/Post.orm.yml
Acme\BlogBundle\Entity\Post:
type: entity
table: post
fields:
createdAt:
type: datetime
nullable: true
updatedAt:
type: datetime
nullable: true
Validate immediately:
php app/console doctrine:schema:validate
php app/console doctrine:schema:update --dump-sql
If the columns appear and flush() populates them, the bundle is working end-to-end and every other behavior follows the same recipe: use the trait, make sure the properties are mapped, flush.
Abstract MappedSuperclass base entity. This is the single highest-leverage pattern with a trait-based bundle. Rather than scattering use TimestampableTrait; across thirty entities, compose the traits once:
namespace Acme\CoreBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Unifik\DoctrineBehaviorsBundle\Behavior\Timestampable\TimestampableTrait;
use Unifik\DoctrineBehaviorsBundle\Behavior\Blameable\BlameableTrait;
/** @ORM\MappedSuperclass @ORM\HasLifecycleCallbacks */
abstract class BaseEntity
{
use TimestampableTrait;
use BlameableTrait;
}
Every concrete entity then just extends BaseEntity. You get consistent audit columns, one place to change behavior, and the generated schema stays uniform.
Audit trail: Timestampable + Blameable together. Timestamps alone rarely answer "who broke this row." Pair the traits and let the bundle's subscriber pull the user from the security token storage. The subscriber typically reads the token storage service; if you have a custom user provider or multiple firewalls, verify it resolves the right user, or override the subscriber service in your services.yml with a lower priority.
Sluggable on a route-facing field. Use the slug trait for the property/getter, but generate the slug deterministically in a lifecycle callback or your own listener rather than relying on a magic guess about the source field:
/** @ORM\PrePersist @ORM\PreUpdate */
public function refreshSlug()
{
$this->slug = $this->slugify($this->title);
}
This keeps the slug under test and avoids surprises when the source field is empty or contains Unicode.
Translatable entities. The usual shape is a translatable entity plus a translation entity linked by a OneToMany/ManyToOne pair, with $entity->translate($locale) returning (and lazily creating) the translation object, and a mergeNewTranslations() call in prePersist to persist newly added locales. Wire the locale from the request in a kernel.request listener, not in the entity.
Forms and serialization. Traits inject properties, but Symfony's form component and JMSSerializer/serialize() won't know about them unless you add the fields to your form types (or mark them read-only/disabled) and add @Serializer\Exclude/@Groups as appropriate. Forgetting this is the most common "why is createdAt null in my API response" bug.
Fixtures and imports. Lifecycle-event-driven behaviors only fire on flush(). Bulk DQL INSERT, raw SQL, and some fixture loaders bypass entity events entirely, so those rows land with null timestamps. Use $em->persist() + $em->flush() in fixtures, or set audit fields explicitly.
Soft deletes. If the bundle provides a soft-delete behavior, remember it's two pieces: the trait/column and the Doctrine filter that excludes deleted rows. Enable the filter in config.yml (doctrine.orm.filters), otherwise every findAll() in the app returns tombstones.
This is an abandoned Symfony 2-era package. With 0 dependents and 11 stars, assume no fixes are coming. Read the composer.json for the Symfony/PHP constraints before adopting; PHP 5.4 traits-era code often breaks on PHP 7.2+ due to signature or count() semantics, and definitely on PHP 8. Pin an exact commit and write a smoke test that flushes one entity per behavior so an upgrade doesn't silently break audit columns.
Trait method collisions fail at class-definition time, not at runtime. If two traits both declare a getUpdatedAt(), or your entity already defines a method a trait provides, you get a fatal error the moment the class loads — and the message points at the class, not the traits. When combining multiple behaviors, alias explicitly:
use TimestampableTrait, BlameableTrait {
TimestampableTrait::getCreatedAt insteadof BlameableTrait;
}
Doctrine annotation mapping inside traits is version-sensitive. Historically the annotation driver had gaps reading property annotations declared in traits. If doctrine:schema:validate says the field doesn't exist but the trait clearly declares it, stop debugging and map the columns explicitly in YAML/XML. That's a five-minute fix versus an hour of reflection spelunking.
preUpdate won't fire if nothing changed. updatedAt only moves when Doctrine detects a change set. If you're relying on it for cache invalidation or "last touched" ordering, be aware that re-saving an identical entity leaves the old value. Force it with $em->getUnitOfWork()->scheduleForUpdate($entity) or set the field manually.
Blameable is null on the CLI. Console commands, fixtures, and message-queue workers have no security token. Guard the subscriber or accept nullable columns; a null createdBy will otherwise blow up a NOT NULL constraint in production crons.
Slug uniqueness is your job. Generated slugs collide on duplicate titles ("hello-world", "hello-world"). Add a unique index and a collision-suffix loop, or you'll discover it when a customer renames a page.
Clear metadata cache after touching traits.
php app/console doctrine:cache:clear-metadata
php app/console cache:clear
Trait changes don't always invalidate the cached mapping, producing "column already exists" or "no such column" errors that vanish on a cache clear.
Extension points, in order of preference:
MappedSuperclass composition (above) — no bundle changes needed.services.yml under the same service id, or via a compiler pass, to change the user resolution or slug source.Acme\DoctrineBehaviorsBundle and owning the few traits you actually use is often cheaper than fighting an unmaintained dependency.Debugging tip: subscribe to onFlush in a throwaway listener, log $uow->getScheduledEntityInsertions(), getScheduledEntityUpdates(), and the change sets, and confirm whether the bundle's subscriber ran before or after yours. Most "behavior didn't apply" reports are an ordering issue between event subscribers, not a broken trait.
How can I help you explore Laravel packages today?