ufo-tech/doctrine-behaviors
Add reusable Doctrine entity behaviors to your Laravel app: timestamps, slugs, soft deletes, tree structures, and more. Drop-in traits keep entities consistent and cut boilerplate, so you write less mapping code and ship features faster. Great for teams standardizing entity logic.
This is a maintenance fork whose entire reason to exist is Symfony 6.4/7 compatibility for knplabs/doctrine-behaviors. You don't adopt it for new features — you adopt it because your upgrade to Symfony 7 is blocked by an entity model that leans on Timestampable, SoftDeletable, Sluggable or Translatable. Plan for it as a bridge, not a destination.
1. Confirm what you're replacing. Check the current lock file first:
composer why knplabs/doctrine-behaviors
composer show knplabs/doctrine-behaviors # note the version you're on today
2. Require the fork, pinning it. With 8 stars and zero dependents, treat the version constraint as a contract:
composer remove knplabs/doctrine-behaviors
composer require ufo-tech/doctrine-behaviors:0.1.* # pin; don't use ^ unless you've read the tags
If the fork declares replace: knplabs/doctrine-behaviors you can skip the remove, but doing it explicitly avoids a "two packages, same namespace" autoload mess.
3. Inspect the install before writing code. Two things decide everything downstream:
cat vendor/ufo-tech/doctrine-behaviors/composer.json # autoload PSR-4 namespace, symfony/* constraints
ls vendor/ufo-tech/doctrine-behaviors/src
The fork keeps the Knp\DoctrineBehaviors namespace so migrations are a no-op for your use statements — verify that in the autoload block, because if it was renamed you have a global find/replace to do instead.
4. Register the bundle manually. There is no Flex recipe for this package, so nothing is auto-wired:
// config/bundles.php
return [
// ...
Knp\DoctrineBehaviors\Bundle\DoctrineBehaviorsBundle::class => ['all' => true],
];
5. Read the real config tree rather than trusting docs written for upstream:
bin/console debug:config knp_doctrine_behaviors
# config/packages/knp_doctrine_behaviors.yaml
knp_doctrine_behaviors:
timestampable: ~
soft_deletable: ~
sluggable: ~
blameable:
user_callable: 'App\Doctrine\UserCallable'
6. First use case: Timestampable. It's the cheapest behavior to validate the wiring end-to-end.
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Model\Timestampable\TimestampableTrait;
#[ORM\Entity]
class Article
{
use TimestampableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
}
bin/console doctrine:schema:update --dump-sql
If created_at / updated_at show up, the listeners are tagged and the bundle is live. If the SQL is empty, the columns aren't being read from the trait — see the attribute-override note in Gotchas.
Almost every real codebase ends up with the same four traits on every entity. Put them in one place:
#[ORM\MappedSuperclass]
abstract class AbstractEntity
{
use TimestampableTrait;
use SoftDeletableTrait;
use UuidableTrait;
use BlameableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
protected ?int $id = null;
}
Then class Article extends AbstractEntity {}. Traits on a mapped superclass are inherited correctly by every subclass, and you get consistent schema without repeating use lines. Keep SluggableTrait and TranslatableTrait off this base — they impose required methods and extra entities.
SoftDeletableTrait gives you delete()/undelete()/isDeleted(); the SQL filtering is a Doctrine filter, not magic in the trait. Enforce it in one place:
abstract class BaseRepository extends EntityRepository
{
public function findVisible(int $id): ?object
{
return $this->createQueryBuilder('e')
->where('e.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult(); // filter applies to DQL automatically
}
/** @return object[] */
public function findTrashed(): array
{
$filters = $this->getEntityManager()->getFilters();
$filters->disable(SoftDeletableFilter::class);
try {
return $this->findAll();
} finally {
$filters->enable(SoftDeletableFilter::class);
}
}
}
Enable the filter at boot (doctrine.orm.entity_managers.*.filters in config, or a kernel.request listener) so it can never be "off by accident" in web requests.
class Article
{
use SluggableTrait;
#[ORM\Column(unique: true)]
private string $slug;
public function getSluggableFields(): array
{
return ['title'];
}
}
The listener generates on prePersist and, by default, does not regenerate when the title changes on an existing entity. For CMS-style routes you usually want that stability (URLs shouldn't break); for admin back-offices you want a "regenerate" button that sets $article->setSlug('') and flushes.
Two entities, always. Parent holds locale-independent data; translation holds the localizable fields.
#[ORM\Entity]
class Article
{
use TranslatableTrait;
#[ORM\OneToMany(
targetEntity: ArticleTranslation::class,
mappedBy: 'translatable',
cascade: ['persist', 'remove'],
orphanRemoval: true,
indexBy: 'locale',
)]
protected $translations;
public function getTitle(): ?string
{
return $this->translate()->getTitle();
}
}
#[ORM\Entity]
class ArticleTranslation
{
use TranslationTrait;
#[ORM\Column(nullable: true)]
private ?string $title = null;
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'translations')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
protected $translatable;
}
indexBy: 'locale' is important — it makes $article->getTranslations()['fr'] work and avoids a linear scan in translate($locale) on every request.
Querying across translations:
$qb = $repository->createQueryBuilder('a')
->join('a.translations', 't')
->andWhere('t.locale = :locale')
->andWhere('t.title LIKE :q')
->setParameter('locale', $request->getLocale())
->setParameter('q', '%'.$term.'%');
Admin form pattern: one locale tab per configured locale, backed by a CollectionType of ArticleTranslationType with by_reference => false, plus a PRE_SUBMIT listener that lazily calls $article->translate($locale) so every tab has an entity to bind to.
Don't reach for the token storage inside the listener — inject a callable that resolves the acting user for both HTTP and CLI:
final class UserCallable
{
public function __construct(private TokenStorageInterface $tokens) {}
public function __invoke(): ?UserInterface
{
return $this->tokens->getToken()?->getUser();
}
}
Wire it as knp_doctrine_behaviors.blameable.user_callable: 'App\Doctrine\UserCallable' (or %app.user_callable% as a container parameter, which is how several versions of this bundle expect it). Console commands and Messenger workers will produce null — decide deliberately whether created_by is nullable.
NodeTrait + the package's NodeRepositoryTrait gives you getRootNodes(), getChildren(), getParent() and materialized childCount maintenance. It's a genuine fit for categories and menus, and a poor fit for anything with high write concurrency; childCount denormalization is maintained by a listener and drifts the moment you run a bulk UPDATE ... SET parent_id = ....
Every behavior is a Doctrine event subscriber registered by the bundle. If you need custom column names or extra columns, use #[ORM\AttributeOverride] on the entity rather than re-declaring the property:
#[ORM\Entity]
#[ORM\AttributeOverride(name: 'createdAt', column: new ORM\Column(name: 'created_at', type: 'datetime_immutable'))]
class Article extends AbstractEntity {}
This is a supply-chain decision as much as a technical one. 8 stars, 0 dependents, MIT. Pin the exact version in composer.json, commit the composer.lock, and write down the exit plan. The good news: because the namespace is Knp\..., when upstream ships Symfony 7 support you swap it back with a single composer require and zero code changes. Keep that property intact — never use UFO\DoctrineBehaviors\... in your app code.
Vendor-copy is a legitimate fallback. Ten small traits plus a bundle class is a few thousand lines. If the fork goes quiet, src/ can be lifted into App\Doctrine\Behaviors with a namespace rewrite and you're unblocked forever. Budget half a day for that option before you need it.
No Flex recipe means no auto-config. Symptom: created_at is always null. Cause: bundle not registered, or registered but the service definitions aren't tagged. Diagnose with bin/console debug:event-dispatcher doctrine.eventSubscriber and bin/console doctrine:mapping:info.
Traits carry the mapping via PHP attributes. Doctrine reads properties through ReflectionClass::getProperties(), which includes trait properties, so the columns come from the trait itself. If they don't appear, your entity is being read by a driver that doesn't traverse traits, or the fork's traits don't declare mapping at all (older 1.x-style API expects you to add the columns). doctrine:mapping:info and one --dump-sql settles it in thirty seconds; don't guess.
Soft delete breaks unique constraints. A soft-deleted Article with slug = 'hello' still occupies the unique index, so re-creating "Hello" fails. Either make the unique index (slug, deleted_at) or null the field in a preSoftDelete hook. This bug always surfaces in production, never in tests.
Soft delete filters don't apply to native SQL. $em->getConnection()->executeQuery('SELECT * FROM article') returns deleted rows. Same for NativeQuery. Keep raw SQL away from soft-deletable tables, or add deleted_at IS NULL by hand and comment why.
Bulk DQL bypasses everything. UPDATE article SET title = :t skips Timestampable and Blameable, so updated_at stays stale and updated_by lies. Batch jobs that must move timestamps should $em->flush() per entity or set the fields explicitly in the DQL.
updated_at doesn't move on a no-op flush. Doctrine only fires preUpdate when the change set is non-empty. $article->setTitle($sameTitle); flush(); changes nothing — correct behavior, but it confuses people testing the listener.
Translatable cascade omissions produce the classic error. Forgetting cascade: ['persist'] gives A new entity was found through the relationship 'Article#translations'. Forgetting orphanRemoval: true leaves orphan rows after removing a locale. Both belong in the entity, not in a service.
Translatable locale in CLI is silently the default. The locale provider reads the request; console commands and workers fall back to kernel.default_locale. If a command writes translations, set the locale explicitly (setCurrentLocale() / the provider's setter) or you'll write everything into en.
Unique translation fields go on the translation table. A unique index on article_translation should be (translatable_id, locale) for one-value-per-locale, or (locale, slug) if slugs must be globally unique per language. You cannot express any of this on the parent entity.
Sluggable uniqueness races. The -1/-2 suffix is issued by the listener after a lookup; two concurrent inserts can collide and hit the DB unique index. Catch the UniqueConstraintViolationException and retry, or accept the 500 as a "very rare" trade.
Blameable on preUpdate only sets updated_by. created_by is written once. Don't write assertions that both are always non-null when you also write the entities from tests without a security token.
Translatable + indexBy: 'locale' changes the collection shape. getTranslations() is a keyed collection, so array_map over it and json_encode behave differently than a list. It's worth it for the lookup speed; be aware when serializing to an API.
Extension points are just services. The locale provider, the user callable, the slugger and the geocoder are all injected services, so you can swap them via config or decorate them (decorates:) in your own services.yaml. That's the clean seam for multi-tenant "current user" or per-channel locales — reach for it before forking the listener.
Write one functional test that proves the behaviors fire. A KernelTestCase against SQLite that persists an entity and asserts createdAt, slug, uuid and one translated field is ~40 lines and is the only thing standing between you and a Symfony 7 upgrade that silently stops populating slug. With zero public dependents, your test suite is the de facto compatibility matrix.
How can I help you explore Laravel packages today?