gedmo/doctrine-extensions
Doctrine Behavioral Extensions adds powerful behaviors to Doctrine ORM and MongoDB ODM, including Sluggable, Timestampable, Blameable, Loggable, and Translatable, easily attached via Doctrine's event system.
Decision point first. gedmo/doctrine-extensions is a Doctrine ORM/ODM add-on, not an Eloquent one. It buys you nothing unless you already run Doctrine in your Laravel app (typically via laravel-doctrine/orm). If your models are Eloquent, the honest answer is to skip this package — the friction of splitting your persistence layer is larger than anything Timestampable or Sluggable saves you. If you do run Doctrine (CMS modules, legacy Symfony ports, event-sourced admin panels), this is close to mandatory infrastructure.
Minimal setup in a Laravel app that already has Doctrine wired up:
composer require gedmo/doctrine-extensions
Then register only the listeners you actually use, per entity manager. With laravel-doctrine/extensions:
// config/doctrine.php
'managers' => [
'default' => [
'meta' => 'attribute',
'extensions' => [
Gedmo\Timestampable\TimestampableListener::class,
Gedmo\Sluggable\SluggableListener::class,
],
],
],
If you wire Doctrine manually, add them to the event manager yourself (order matters, see below):
$evm = $em->getEventManager();
$evm->addEventSubscriber(new TimestampableListener());
$evm->addEventSubscriber(new SluggableListener());
First use case — a Post with zero-effort timestamps and a URL slug:
#[ORM\Entity]
#[ORM\Table(name: 'posts')]
class Post
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 200)]
private string $title = '';
#[Gedmo\Sluggable(fields: ['title'])]
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $slug = '';
#[Gedmo\Timestampable(on: 'create')]
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[Gedmo\Timestampable(on: 'update')]
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
}
$post = new Post();
$post->setTitle('Hello World');
$em->persist($post);
$em->flush(); // slug = "hello-world", createdAt/updatedAt populated
Read first: doc/timestampable.md, doc/sluggable.md, docs/attributes.md (attributes are the supported path; annotations are deprecated and removed in 4.0), and doc/frameworks/laravel.md. Use the example/em.php bootstrap if your EntityManager setup throws listener/metadata errors.
Timestampable / Blameable via mapped superclass. Don't copy timestamp fields into 40 entities. Extend a BaseEntity with #[ORM\MappedSuperclass] carrying createdAt, updatedAt, createdBy, updatedBy. This is the single highest-leverage use of the package in a day-to-day codebase.
Blameable needs a per-request user. The listener holds state, so push the authenticated user at boot (or from middleware) into the registered instance:
$evm = app('em')->getEventManager();
foreach ($evm->getListeners(\Doctrine\ORM\Events::onFlush) as $listener) {
if ($listener instanceof \Gedmo\Blameable\BlameableListener) {
$listener->setUserValue(auth()->user()); // object for relations, string/id for scalar fields
}
}
Do the same for TranslatableListener::setTranslatableLocale() — set it in a locale middleware, or per entity via the Gedmo\Translatable\Translatable interface when you translate several records in one request.
Sluggable — treat it as URL infrastructure. Use fields: ['title'] for simple cases, unique: true plus a DB unique index for public routes, and a custom SlugHandlerInterface (e.g. tree path handlers) when the slug must include parent segments. Set updatable: false on anything that has been published; regenerating slugs silently is a broken-links generator.
SoftDeleteable — opt-in via the SQL filter, not the listener alone. The listener only sets the date; you must enable the filter on each manager:
app('em')->getFilters()->enable('soft-deleteable');
For admin views, disable it around the query (->disable(), then re-enable in a finally) or expose a withTrashed() repository method. Keep this in one place — scattered toggles are how "deleted" rows leak into customer-facing listings.
Tree — pick by read pattern. Nested set (#[Gedmo\Tree(type: 'nested')] + TreeLeft/TreeRight/TreeLevel) is ideal for menu/category hierarchies read far more than written: getChildren(), getPath(), childrenHierarchy(), moveDown(). Materialized path is easier to write and cheaper to move; closure is best when you need arbitrary ancestor queries but costs an extra table. Use one strategy per aggregate.
Translatable. Requires a translation entity (extend Gedmo\Translatable\Entity\MappedSuperclass\AbstractTranslation), a locale per request, and the walker for reads:
$query->setHint(
\Doctrine\ORM\Query::HINT_CUSTOM_OUTPUT_WALKER,
\Gedmo\Translatable\Query\TreeWalker\TranslationWalker::class
);
$query->setHint(\Gedmo\Translatable\TranslatableListener::HINT_FALLBACK, 1);
Loggable for audit trails: mark the entity #[Gedmo\Loggable], the fields #[Gedmo\Versioned], and register a LogEntry entity extending AbstractLogEntry. Set LoggableListener::setUsername(...) from the same middleware as Blameable.
Sortable for manually ordered collections (banners, dashboard widgets): #[Gedmo\SortablePosition] plus a class-level #[Gedmo\Sortable(group: ['category'])]. Wrap reorders in $em->transactional() so positions can't tear.
General workflow tip: wrap mutations in transactions and keep one flush() per logical operation. Gedmo listeners run inside onFlush/prePersist, so they see the whole UnitOfWork — flushing per-row makes Sluggable and Sortable do N queries instead of 1.
EntityManager (multi-manager apps), stale metadata cache (php artisan doctrine:clear:metadata:cache after adding attributes), or a missing use Gedmo\Mapping\Annotation as Gedmo; so #[Gedmo\Sluggable] resolved to the wrong class.doctrine/annotations and are slated for removal in 4.0. If you're on Laravel + PHP 8, never start with annotations.email or slug blocks re-creating a record whose predecessor is soft-deleted. Either drop the DB constraint for those columns, or partial-index/nullable-token it; don't discover this in production.$em->clear() between units of work; otherwise job #2 gets job #1's user as createdBy.SELECT. For bulk imports, rows pending in the same flush aren't visible to it — dedupe source values in application code before flushing batches.tree:recover-style verification in your ops toolbox.date_default_timezone_set() with config('app.timezone'), or timestamps drift against Eloquent-written tables.^3.2, not ^4.0) and grows a table forever. Plan retention/archiving from day one.composer.json since listener APIs changed.$evm->addEventSubscriber(new TimestampableListener())) on an in-memory SQLite EM; don't assume the framework wiring is active.SlugHandlerInterface implementations, custom tree strategies via AbstractTreeListener, a custom LogEntry class, and a custom Gedmo\Mapping\Driver if you have in-house mapping metadata.How can I help you explore Laravel packages today?