miets/doctrine-behaviors
Doctrine ORM behavior extensions: reusable traits, event listeners and mappings that add timestampable, sluggable, soft-deletable, blameable and tree behavior to entities. Cuts boilerplate, keeps models clean, and drops into existing Doctrine setups with simple config.
composer require miets/doctrine-behaviors
// config/bundles.php
Miets\DoctrineBehaviors\DoctrineBehaviorsBundle::class => ['all' => true],
knplabs/doctrine-behaviors, so class names and the YAML root key may differ (doctrine_behaviors vs. the legacy knp_doctrine_behaviors). Inspect what you actually installed:bin/console config:dump-reference doctrine_behaviors # try the likely keys
ls vendor/miets/doctrine-behaviors/src
Where to look first, in this order:
vendor/miets/doctrine-behaviors/src/Model/ — the traits themselves (TimestampableTrait, SluggableTrait, SoftDeletableTrait, TranslatableTrait, BlameableTrait, LoggableTrait, TreeNodeTrait, UuidableTrait).src/Doctrine/EventListener/ (or EventSubscriber/) — how each behavior hooks prePersist / preUpdate / postLoad.README.md and DependencyInjection/Configuration.php for the exact config schema.First use case — Timestampable, since it has zero external dependencies and immediately de-risks a new install:
use Miets\DoctrineBehaviors\Model\Timestampable\TimestampableTrait;
#[ORM\Entity]
class Article
{
use TimestampableTrait;
}
Then bin/console make:migration and confirm the created_at / updated_at columns appear. If they do, the bundle is wired correctly and the other traits will follow the same pattern.
Trait + migration is the whole loop. Every behavior is (a) a trait you use in the entity, and (b) a Doctrine listener that populates fields. Your workflow is always: add trait → make:migration → review the diff → done. Never hand-write the columns.
Timestampable — pure freebie, no config. Read getCreatedAt() / getUpdatedAt() in serializers and admin grids; never expose them as writable form fields.
Sluggable — the only pattern where you must implement a method:
use SluggableTrait;
public function getSluggableFields(): array
{
return ['title'];
}
Use it for SEO routes (/blog/{slug}) and treat the slug as a stable identifier — regenerate explicitly, not implicitly, if URLs must survive title edits.
Blameable / Loggable — configure a user callable (or user provider service) in the bundle config so the listeners know who the current user is. Typical usage:
# config/packages/doctrine_behaviors.yaml
doctrine_behaviors:
blameable:
user_callable: 'App\Security\CurrentUserProvider::getUser'
loggable:
user_callable: 'App\Security\CurrentUserProvider::getUser'
Use Blameable for createdBy / updatedBy audit columns on business entities, and Loggable only on entities with real audit requirements — it writes a row per change and grows fast.
SoftDeletable — the crucial pattern is that $em->remove($entity) does not soft-delete it. You must go through the soft-deletable manager:
$softDeletableManager->remove($article); // sets deletedAt
$em->flush();
For read paths, rely on the query filter (enabled by default) and disable it explicitly in admin/reporting screens: $filters->disable('soft_delete').
Translatable — two entities per translatable model: the main entity (use TranslatableTrait) and a separate ArticleTranslation (use TranslationTrait) holding locale-specific fields, with a unique index on (translatable_id, locale). In controllers, fetch with a join to the translation for the current locale to avoid N+1, and let the framework's locale provider drive currentLocale. Forms for translated entities need per-locale field groups — this is the single biggest day-to-day integration cost of the package.
Tree — use for categories/menus. Persist by setting the parent and flushing; call repository methods (getChildren, getPath, getRootNodes) rather than hand-joining on parent_id. Reordering is best done in one transaction, then $em->clear() if you see stale hierarchy in memory.
Uuidable — swap the auto-increment id for a UUID on entities that are exposed in URLs or synced across systems. Pick binary vs. string storage in config and stick to it; mixing the two across entities makes joins painful.
Testing pattern — freeze time for Timestampable assertions (ClockMock or a fixed DateTimeImmutable), create entities through the entity manager (not new alone) so listeners fire, and disable query filters in fixtures where you need to assert on soft-deleted rows.
knplabs/doctrine-behaviors upstream, gedmo/doctrine-extensions, or plain #[ORM\PrePersist] / #[ORM\PreUpdate] lifecycle callbacks for the two or three behaviors you actually need.knp_doctrine_behaviors. If config:dump-reference errors, read DependencyInjection/Configuration.php in vendor — guessing the key wastes an hour.$createdAt, $slug, or $id, PHP fatal-errors on duplicate trait properties. Audit the entity before adding a trait, and rename your own columns rather than the trait's.UPDATE article SET title = ... will not touch updatedAt. Either use $em->flush() on managed entities or set the timestamp explicitly in the DQL.NotNull violation. Make the callable return null safely and keep the columns nullable.remove($entity) calls, raw SQL, and cascade deletes all hard-delete. Add a code-review checklist item and, ideally, a repository base class that only exposes the manager's remove.getTranslations() per row in a list. Always query with the translation joined for the active locale and check EXPLAIN on listing endpoints.NOT NULL with no default on a table with rows — the migration fails on deploy. Add nullable: true or a data backfill in the same migration.bin/console debug:event-dispatcher doctrine.event_subscriber, and check whether fields are actually populated by inspecting the SQL log (doctrine.dbal.logging: true in dev). If a behavior "doesn't work," it's almost always a missing trait, an unregistered bundle, or a bypassed listener — in that order.How can I help you explore Laravel packages today?