mkietlinski/doctrine-extensions-bundle
A Symfony bundle that integrates Gedmo's Doctrine extensions, making it easy to register and configure behaviors like timestampable, sluggable, softdelete, tree, translatable, and blameable across your entities with minimal setup.
First, establish what stack you're actually on. This package is a Symfony bundle (Bundle class + DependencyInjection extension + stof_doctrine_extensions config tree). Laravel's container never discovers or boots it, so on a Laravel/Eloquent codebase there is no day-to-day usage to speak of — composer require gets you a namespace nothing calls. If you are on laravel-doctrine/orm, skip this package and depend on gedmo/doctrine-extensions directly; the registration is ~20 lines in a service provider.
If you are on Symfony + Doctrine ORM, the minimal path is:
composer require mkietlinski/doctrine-extensions-bundle and register Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle in config/bundles.php (Flex auto-registers it if the recipe exists; this fork probably has none, so register by hand).config/packages/stof_doctrine_extensions.yaml and enable one extension on one entity manager:
stof_doctrine_extensions:
orm:
default:
timestampable: true
The default key must match an entity manager name from doctrine.yaml (doctrine.orm.entity_manager → default). A mismatch is the single most common "why isn't this firing" cause.#[Gedmo\Timestampable(on: 'create')] on createdAt.bin/console cache:clear && bin/console doctrine:schema:update --dump-sql (or make a migration).createdAt is populated — this proves the listener is subscribed, which is the only thing the bundle actually does for you.Where to look first, in order: the fork's DependencyInjection/Configuration.php (the real, authoritative list of what is togglable — the README is a copy of upstream and may drift), then diff that file against upstream stof/doctrine-extensions-bundle so you know what you're actually running, then upstream's docs for entity-side attribute syntax (those are Gedmo's, not the bundle's).
First real use case: Timestampable + SoftDeleteable on one low-risk entity. Those two are the highest value-to-risk ratio and exercise both the write path (timestamps) and the read path (the softdeleteable query filter), which are the two places this class of package breaks.
Config is per-entity-manager, and that's the main lever. Everything under orm.<em_name> is registered only against that EM's EventManager. Multi-EM apps (read/write split, tenant connections) should enable extensions explicitly per EM rather than globally — enabling loggable or translatable on a EM that doesn't need it adds a listener on every flush for no return.
Enable behavior-by-behavior, not all at once. Start with timestampable, ship, then add sluggable, then softdeleteable. Each one changes query shape or write behavior, and a combined rollout makes it impossible to attribute a regression to a specific listener.
Entity side is plain Gedmo attributes — the bundle contributes nothing here:
#[Gedmo\Timestampable(on: 'create')]
private ?\DateTimeImmutable $createdAt = null;
#[Gedmo\Slug(fields: ['title'], unique: true)]
private ?string $slug = null;
#[Gedmo\SoftDeleteable(fieldName: 'deletedAt', timeAware: false, hardDelete: false)]
class Post { private ?\DateTimeImmutable $deletedAt = null; }
Soft delete is a read-filter, not a column. Day-to-day this means:
$em->getFilters()->disable('softdeleteable');
try { /* query including deleted */ } finally { $em->getFilters()->enable('softdeleteable'); }
Wrap in try/finally — a filter left disabled leaks deleted records into unrelated responses in the same request, including API serialization.$entity->setDeletedAt(null); $em->flush(); — no unfiltered query needed if you already hold the reference.Blameable needs a user source. The listener has no opinion about who is logged in. Wire it once in a kernel/request listener or a Doctrine event subscriber that resolves the current user from the security token and calls setUserValue() on the gedmo.listener.blameable (aliased by the bundle) service before flush. Do this before you enable blameable on any entity, otherwise every row is attributed to null.
Loggable is the audit trail; pair it with a retention decision. It writes a LogEntry-style row per change per entity. On write-heavy tables budget for the secondary write and set a cleanup job from day one — retrofitting a purge onto a bloated audit table is a migration project.
Translatable: set the locale explicitly per operation. The listener reads the locale from its own state, not from the request. Set it ($listener->setTranslatableLocale($locale) or via the TranslatableListener service) and reset it after the flush, or you'll get the previous request's locale leaking into the next one under long-running workers.
Tree (nested set) day-to-day: use the tree repository from $em->getRepository(Category::class) (it's a NestedTreeRepository when the mapping is present), and treat every insert/move as a multi-row write. Don't call moveUp()/moveDown() in a loop over a large set — batch the operations and wrap them in a transaction.
Testing pattern that actually catches failures: assert the effect, not the absence of exceptions. assertNotNull($post->getCreatedAt()) after flush, assertNotContains($deleted, $repo->findAll()) before disabling the filter and assertContains(...) after. A listener that failed to register produces no error — only a null column — so error-free test runs prove nothing here.
timestampable: false or a missing key produces a perfectly bootable app where timestamps are simply never written. After any config change, run bin/console cache:clear (and doctrine:cache:clear-metadata outside dev) and re-verify with a real flush. YAML config is compiled into the container; stale cache means your edit did nothing.stof_doctrine_extensions.orm.<name> must exactly match a configured EM. Typos are accepted silently at config level and produce no listeners at runtime.#[Gedmo\Slug] isn't taking effect, it's the metadata cache, not the bundle.getFilters()->disable() without a finally re-enable is a latent incident, especially in queues and long-lived workers where the EM survives between jobs.mkietlinski/doctrine-extensions-bundle) differs from the repository name (StofDoctrineExtensionsBundle) — the fork retains the Stof\ namespace and the stof_doctrine_extensions config key. That's annoying for auditing but genuinely useful operationally: swapping to upstream stof/doctrine-extensions-bundle is a composer.json line plus a bundle-class check, with zero config migration. Pin the exact version ("mkietlinski/doctrine-extensions-bundle": "x.y.z"), not a caret range, so a fork-side change can't arrive silently.gedmo/doctrine-extensions alongside it. Gedmo tracks Doctrine ORM internals closely and breaks across minor ORM upgrades. A transitive bump that satisfies the fork's constraint but not your ORM's mapping expectations shows up as a bootstrap mapping exception — loud, but it blocks unrelated dependency upgrades until resolved.verify()/tree rebuild rather than debugging queries.gedmo.listener.* services — the bundle exposes them and steps aside. Don't patch the vendor tree; the fork has no maintainer throughput to absorb or upstream your patches.$em->getEventManager()->addEventSubscriber(...) in a small service. It's ~5 lines per listener and removes the bundle from your debugging surface entirely — a reasonable trade for a package with no dependents and no community issue history to search.How can I help you explore Laravel packages today?