toovalu-github/doctrine-behaviors
Doctrine Behaviors adds reusable entity traits and listeners to Doctrine ORM, including timestampable, sluggable, soft-deletable, blameable, and tree behavior, so common model logic stays out of your entities and your Laravel or PHP app stays clean.
Install and register the bundle first, then probe the vendor tree before writing a line of domain code — this is a fork, and namespaces/package names may differ from upstream knplabs/doctrine-behaviors.
composer require toovalu-github/doctrine-behaviors
grep -r "class DoctrineBehaviorsBundle" vendor/toovalu-github/doctrine-behaviors/src
ls vendor/toovalu-github/doctrine-behaviors/src/Model
Register the bundle in config/bundles.php (namespace per the grep above — most forks keep Knp\DoctrineBehaviors\Bundle\DoctrineBehaviorsBundle):
Knp\DoctrineBehaviors\Bundle\DoctrineBehaviorsBundle::class => ['all' => true],
There is no mandatory configuration: a behavior activates when an entity both implements the contract interface and uses the matching trait. That pair is the whole mental model.
First use case — Timestampable + Sluggable on a Post:
use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Contract\Entity\SluggableInterface;
use Knp\DoctrineBehaviors\Contract\Entity\TimestampableInterface;
use Knp\DoctrineBehaviors\Model\Sluggable\SluggableTrait;
use Knp\DoctrineBehaviors\Model\Timestampable\TimestampableTrait;
#[ORM\Entity]
class Post implements SluggableInterface, TimestampableInterface
{
use SluggableTrait;
use TimestampableTrait;
public function getSluggableFields(): array
{
return ['title'];
}
public function shouldRegenerateSlugOnUpdate(): bool
{
return false; // keep permalinks stable
}
}
Then bin/console doctrine:migrations:diff and read the generated migration — you should see created_at, updated_at, and slug columns. If any are missing, the interface is missing.
Pick trait vs. mapped superclass deliberately. TimestampableEntity and SoftDeletableEntity are ready-made mapped superclasses that already embed the trait and the column mapping. Don't use TimestampableTrait and extend TimestampableEntity; you'll get duplicate createdAt mappings.
Blameable adds createdBy/updatedBy relations to your user entity. Let the bundle resolve the current user from the security token for web requests, and inject an explicit user in CLI, fixtures, and messenger consumers — otherwise the listener no-ops and your nullable columns stay null (or non-nullable columns blow up).
SoftDeletable is a filter, not an override of remove(). You call $post->softDelete() / $post->restore(), and reads are filtered only where the filter is enabled:
$em->getFilters()->enable('soft_deletable'); // confirm exact name via getFilters()->getFilters()
Wrap this in a request listener or custom repository base class so it is consistent; enable it for admin "trash" screens only when you actually want deleted rows.
Translatable is a two-entity pattern: a parent with TranslatableTrait and a translation entity with TranslationTrait.
#[ORM\Entity]
class PostTranslation implements TranslationInterface
{
use TranslationTrait;
#[ORM\Column(nullable: true)]
private ?string $title = null;
// getters/setters
}
Then $post->translate('en')->setTitle('Hello') returns the existing or newly created translation, and $post->getTranslation('fr') reads it. Persistence flows through cascade: ['persist'] on the parent's translations association — verify the trait sets that, and add orphanRemoval yourself if you edit translations through forms.
Uuidable works best as a secondary public identifier next to an auto-increment primary key. Using it as the primary key forces you to solve UUID generation before flush in every test factory.
Sluggable is where you should spend your design budget: put a unique index on slug, and decide up front whether shouldRegenerateSlugOnUpdate() is true. For CMS content it should be false.
Tree is the least-exercised behavior in these packages. Before building navigation on it, write a throwaway integration test that adds children, moves a node, and reads it back through your Doctrine version.
Form integration for Translatable: a CollectionType of translation forms with by_reference => false, plus cascade: ['persist']. Render one tab per locale from a fixed locale list, not from existing translations, so editors can create the first translation.
composer.json and diff vendor/toovalu-github/doctrine-behaviors against knplabs/doctrine-behaviors before adopting. If the delta is one or two patches, prefer a cweagans/composer-patches entry or a VCS repository pointed at a commit — you keep upstream's fixes and your own diff visible.SluggableInterface but forget SluggableTrait and you get no slug column, no error. Symptom: "the listener never fires." Check the interface first, always.diff after adding a behavior sometimes misses association changes (blameable user FKs, translation tables). Run diff, review, then diff again to confirm it's empty.my-title. Either make the unique index composite (slug, deleted_at) or query for uniqueness including soft-deleted rows in your slug factory.updatedAt doesn't move on bulk DQL. UPDATE Post p SET ... bypasses listeners entirely. If audit accuracy matters, use entity-level updates or set the timestamp in the same DQL.getTranslation($locale) per row is 50 queries. Join translations in the repository query with a locale condition, or use a query hint to eager-load.createdBy nullable, or configure a callable/system user so audit rows are attributable.doctrine:fixtures:load; otherwise a half-populated audit column will look like a data bug three weeks later.getSluggableFields() to compute the slug source dynamically (e.g. include a category prefix), decorate the slug generator service for custom transliteration, and override setUpdatedAt()/setCreatedAt() in your entity if you need to backfill legacy data.How can I help you explore Laravel packages today?