zitec/doctrine-behaviors
A Doctrine ORM extension that adds reusable entity behaviors such as timestampable, blameable, soft-deletable, sluggable, and sortable through traits and event subscribers, reducing boilerplate in PHP applications.
zitec/doctrine-behaviors is a maintained fork of knplabs/doctrine-behaviors, so treat the upstream README, blog posts, and Stack Overflow answers as valid documentation — only the namespace differs. It is a set of traits + Doctrine event subscribers, not an admin UI: you use a trait in an entity, register the bundle, and the subscriber does the work on prePersist / onFlush / loadClassMetadata.
Install and register:
composer require zitec/doctrine-behaviors
// config/bundles.php
return [
// ...
Zitec\DoctrineBehaviors\DoctrineBehaviorsBundle::class => ['all' => true],
];
Before writing a line of code, spend ten minutes in the vendor directory:
src/Model/* — the traits you will use (TimestampableTrait, SluggableTrait, BlameableTrait, SoftDeletableTrait, UuidableTrait, LoggableTrait, TranslatableTrait, TreeTrait). This is the source of truth for method names and the exact namespace (Zitec\DoctrineBehaviors\Model\... in the fork).src/EventListener/ (or src/ORM/) — the subscribers; this is where you find out which lifecycle event fires, which is where 90% of surprises come from.src/Resources/config/ — the service definitions and the tag names used for auto-registration.First use case — start with the least invasive behavior, Timestampable, on a real entity:
use Doctrine\ORM\Mapping as ORM;
use Zitec\DoctrineBehaviors\Model\Timestampable\TimestampableTrait;
#[ORM\Entity]
class Post
{
use TimestampableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
}
Do not add #[ORM\Column] for createdAt/updatedAt — the trait/listener maps them. Run bin/console doctrine:schema:update --dump-sql and confirm the two columns appear. If they do, the bundle and the subscriber are wired correctly and you can layer on the rest.
A shared base entity. Rather than repeating traits, define an AbstractEntity with TimestampableTrait + UuidableTrait + BlameableTrait and extend it. Keep SoftDeletableTrait out of the base class if any entity must never be soft-deleted — the listener keys off the trait/interface, so mixing is fine but makes intent murky.
Sluggable requires you to implement one method; without it the listener has nothing to hash:
use Zitec\DoctrineBehaviors\Model\Sluggable\SluggableTrait;
public function getSluggableFields(): array
{
return ['title'];
}
For non-Latin titles, swap the slugifier for a transliteration-aware SlugifierInterface implementation (a service, wired in bundle config) instead of trying to fix results after the fact.
SoftDeletable is the behavior with the highest blast radius. The listener rewrites a scheduled DELETE into an UPDATE deleted_at = NOW(), and the bundle ships a Doctrine filter that hides deleted rows from DQL. Typical workflow:
$em->remove($post); // becomes an UPDATE
$em->flush();
$em->getFilters()->disable('soft_deletable'); // admin / audit views
$em->getRepository(Post::class)->find($id);
$em->getFilters()->enable('soft_deletable');
Expose delete() / restore() from the trait in an admin controller rather than a raw "delete" action, and build one TrashController behind the filter toggle instead of sprinkling disable() calls through the codebase.
Blameable stores the current user on createdBy / updatedBy. In Symfony the bundle pulls the token from security.token_storage; in console commands and workers there is no token, so those columns must be nullable or the write fails. If you store a relation instead of a string, set the user callable in config to an explicit closure — do not rely on the default.
Translatable is the pattern that shapes your domain model, so decide early. Two entities: the translatable one (TranslatableInterface + TranslatableTrait) and a translation entity extending the bundle's Translation class. The translation entity holds all localizable fields; the parent exposes them via magic __call:
$post->translate('en')->setTitle('Hello');
$post->translate('ro', false)->setTitle('Salut'); // false = no fallback creation
$post->getTitle(); // proxied to current locale
Document the proxies with @method string getTitle() on the parent class so PHPStan and your IDE stop complaining. On the Symfony side, render translations as a CollectionType of translation forms with by_reference => false, and make the OneToMany mapping cascade: ['persist', 'remove'], orphanRemoval: true or you will accumulate dead rows.
When listing entities filtered by locale, write the JOIN yourself (JOIN p.translations t WITH t.locale = :locale) — a translatable entity is not automatically locale-scoped in DQL, and getTitle() on a collection-loaded entity triggers one query per row.
Tree is the weakest of the set: it gives you parentNode / childNodes, setParentNode(), and repository helpers for roots and children. It does not maintain depth, path, or ordering for you. For anything beyond a small menu tree — category trees, nested comment threads, sitemaps — either write the recursive CTE / materialized path yourself or reach for gedmo/doctrine-extensions. Use this trait only when "parent + children" is genuinely all you need, and always wrap a re-parent operation in a transaction.
Loggable writes an audit row per change into a LogEntry-style table provided by the bundle. Ship the migration for that table in the same PR that adds the trait, otherwise the first flush() in production explodes.
Combining behaviors is where the package shines: a Post with Timestampable + Blameable + Sluggable + SoftDeletable + Translatable is roughly one trait per concern and zero boilerplate. The cost is that all the logic is implicit — any new developer debugging "why did updated_at change?" has to know the subscribers exist. Add a one-page docs/behaviors.md to the repo listing which entity uses which trait.
Namespace and version drift. Being a fork, the namespace is Zitec\..., not Knp\.... Copy-pasted snippets from the KnpLabs docs will fail on the use statement only — the trait APIs are otherwise close. Pin the version in composer.json and read CHANGELOG.md before upgrading; a minor bump can change listener behavior.
Annotation vs attribute mapping. Recent releases target Doctrine ORM 3, which is attribute-only. If you are still on ORM 2 with annotations, check that the installed tag actually supports your driver — the traits themselves are mapping-agnostic, but the loadClassMetadata subscribers assume the modern API.
Slugs are not unique. The listener generates a slug from the configured fields and stops there. If you need UNIQUE(slug), add a uniqueness strategy (append -2, -3, or a short hash) in a prePersist/preUpdate listener of your own that runs after the bundle's, or you will get integrity-constraint errors on the second "Hello World" post.
Slugs regenerate on update. If title is in getSluggableFields(), editing a title silently changes the URL and breaks inbound links. Freeze the slug by overriding getSluggableFields() to return [] once $this->slug is set, or exclude slug regeneration for published entities.
Filter state leaks between requests in long-running workers. The soft-delete filter is toggled on the EntityManager, and Messenger workers reuse the same EM. If a handler disables the filter and throws, the next message runs with deleted rows visible. Always re-enable in a finally.
The soft-delete filter does not protect raw SQL or NativeQuery. Bulk DELETE statements, DBAL queries, and report SQL bypass the listener entirely. Centralize deletions behind repository methods and audit any DELETE FROM in the codebase.
Soft-deleted entities still satisfy unique constraints and relations. UNIQUE(email) on a soft-deletable User means a deleted user blocks re-registration. Either include deleted_at in the index or anonymize on delete. Likewise, OneToMany collections still return soft-deleted children unless the owning side's mapping is filtered.
$em->remove() vs $entity->delete(). Calling delete() directly only sets the timestamp and leaves the entity managed — the row is not written until you flush() and the listener sees it as a removal. Always go through $em->remove() + flush() and let the listener do the conversion.
Blameable in CLI and async contexts. No token → null in created_by. Make the columns nullable, and if you need attribution from a worker, call the listener's setUser() explicitly before persisting.
Timestampable timezone. The listener uses PHP's default timezone to build the DateTime. Set date.timezone in php.ini/Symfony config consistently across web and workers, or created_at will drift by hours between environments without any code change.
Translatable magic __call fails silently. $post->getTitle() returns null if there is no translation for the current locale and no fallback — it does not throw. Decide per field whether that is acceptable or whether the proxy should throw a domain exception, and encode that in the entity.
Translation uniqueness. Add UNIQUE(translatable_id, locale) on the translation table. Nothing in the bundle prevents two English translations for the same parent, and the magic getter will just return the first one.
Loggable volume. Every flush on a loggable entity writes a row. On a high-traffic entity, add a retention job or restrict logging to specific entities by not applying the trait there.
Debugging missing behavior. When a trait appears to do nothing, check in this order: (1) is the bundle registered? (2) bin/console doctrine:mapping:info — is the column mapped at all? (3) --dump-sql — did the migration pick up the field? (4) Is another prePersist listener (easyadmin, your own audit listener) throwing before the behavior's subscriber runs? Register a temporary logger inside the subscriber to confirm execution order.
Event ordering with other extensions. If you also use stof/doctrine-extensions-bundle or custom onFlush listeners, two listeners may both recompute the changeset and the second one wins. Keep a single onFlush mutation owner per entity.
Extension points worth knowing. Custom slugifier, custom blameable user callable, and subclassing the shipped Translation class are the three hooks you will actually touch. Everything else — changing the column names, storing a UUID as the primary key instead of a secondary column — means reading the subscriber source and patching around it, so budget for that before promising it in a ticket.
How can I help you explore Laravel packages today?