fsi/doctrine-extensions-bundle
FSi DoctrineExtensionsBundle integrates Gedmo/DoctrineExtensions into Symfony, providing configuration and service registration for ORM extensions like Timestampable, Sluggable, Tree, Translatable, SoftDeleteable, and Blameable with minimal setup.
Install and register. The bundle is a thin Symfony integration layer over fsi/doctrine-extensions; it wires the library's Doctrine event subscribers into the container so you don't hand-register them.
composer require fsi/doctrine-extensions-bundle
// config/bundles.php
return [
// ...
FSi\Bundle\DoctrineExtensionsBundle\FSiDoctrineExtensionsBundle::class => ['all' => true],
];
First place to look: the bundle's config tree, not the README. With only 3 stars and no dependent packages, docs are thin and may lag the code. Run bin/console config:dump-reference fsi_doctrine_extensions first — that gives you the authoritative, version-matched key list. Then confirm what was actually registered:
bin/console debug:container --tag=doctrine.event_subscriber | grep -i fsi
bin/console debug:event-dispatcher doctrine.event_subscriber
Minimal config. Extensions are enabled per Doctrine connection (so a multi-EM app only pays for listeners where needed):
# config/packages/fsi_doctrine_extensions.yaml
fsi_doctrine_extensions:
orm:
default:
timestampable: true
blameable: true
softdeleteable: true
sortable: true
First use case — an auditable entity. Start with Timestampable + Blameable on one entity. Add the library trait to the entity, persist it, and confirm createdAt/updatedAt and createdBy/updatedBy populate without you touching them:
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use FSi\DoctrineExtensions\Timestampable\TimestampableTrait;
use FSi\DoctrineExtensions\Blameable\BlameableTrait;
/**
* @ORM\Entity
*/
class Article
{
use TimestampableTrait;
use BlameableTrait;
/** @ORM\Id @ORM\GeneratedValue @ORM\Column(type="integer") */
private ?int $id = null;
/** @ORM\Column(type="string", length=255) */
private string $title = '';
}
If the columns appear after bin/console doctrine:schema:update --dump-sql, the traits are being read correctly by the mapping driver and the subscribers are live. That's your green light for everything else.
Do not enable the traits on a base entity that everything inherits. Doctrine's annotation driver reads trait properties as if declared on the class, so inheritance works, but it drags audit columns and soft-delete semantics onto join tables, log rows, and value objects where they're noise. Compose per aggregate root:
class Invoice
{
use TimestampableTrait; // createdAt / updatedAt
use BlameableTrait; // createdBy / updatedBy
use SoftDeletableTrait; // deletedAt
use SortableTrait; // position
}
The listener asks a resolver "who is acting right now?". The bundle's default resolver reads the Symfony token storage, so it works out of the box with the standard security bundle. In a custom auth setup (API keys, service-to-service, impersonation), decorate the resolver rather than forking the listener:
# config/services.yaml
services:
App\Doctrine\ApiKeyUserResolver:
decorates: fsi_doctrine_extensions.blameable.user_resolver
arguments: ['@security.token_storage', '@request_stack']
Verify the actual service id with bin/console debug:container | grep -i blameable — the id changed across versions, and the decorator silently does nothing if you typo it.
SoftDeletable installs a DQL filter so find()/findBy()/joins skip tombstoned rows. That's the right default. Flip it off only in the specific workflows that need it — an admin "trash" screen, an undelete action, or a GDPR purge job:
$filters = $em->getFilters();
$filterName = 'soft-deletable'; // confirm: $filters->getEnabledFilters()
$filters->disable($filterName);
$deleted = $em->getRepository(Invoice::class)
->findBy(['deletedAt' => /* not null */]);
$filters->enable($filterName);
Always re-enable in a finally. A leaked disabled filter in a long-running worker means deleted records silently reappear in unrelated code paths — one of the nastiest bugs to diagnose.
The listener maintains an integer position per entity (optionally per group). Practical rules:
position from forms and API payloads — it's owned by the listener.flush() once; don't setPosition() in a loop and flush per row.(group_column, position) so a concurrent insert fails loudly instead of producing two rows with the same slot.Listeners fire in fixtures too, which makes assertions brittle. In doctrine:fixtures:load and in KernelTestCase setups, either disable the extensions in the test connection config or set the user resolver to a deterministic fake. For unit tests of the entity itself, the traits are plain properties — you can test them without a kernel.
Because audit columns are populated by listeners during flush, they survive API Platform's serializer round-trip untouched as long as they're on the read group and not the write group. Same for deletedAt. The common mistake is exposing updatedAt as writable — clients then pin it and you lose the audit trail.
Do not run this alongside stof/doctrine-extensions-bundle (Gedmo) on the same entity. Both ship timestampable/blameable/softdeleteable listeners. You get duplicate column writes, inconsistent deletedAt semantics, and two soft-delete filters. Pick one library for a given entity, or scope them to different entity managers.
The library predates PHP 8 attributes. Last release 2021-07-26. Doctrine annotations, XML, or YAML mapping are safe; #[ORM\Column] on trait-injected properties may not be picked up depending on the pinned doctrine/orm. If columns don't appear in schema:update --dump-sql, that's the first thing to check — redeclare the mapping in the entity or stay on annotations.
Compatibility risk is real. Zero dependents and a 2021 last release means Symfony 6.x / Doctrine ORM 3.x support is unverified. Before adopting, run composer why-not fsi/doctrine-extensions-bundle against your target stack and check the library's open issues. For greenfield projects, weigh Gedmo or hand-rolled LifecycleEventSubscribers — these listeners are ~50 lines each to reproduce.
Blameable is null in console/CLI context. No token means no user, so createdBy is null for any row created by bin/console commands, message consumers, or cron. If that's unacceptable, wrap those entry points in a synthetic token (Security::setToken()) or your resolver should fall back to a system principal.
Soft delete breaks unique constraints. A UNIQUE(email) on a soft-deletable User means the tombstoned row still blocks re-registration. Either drop the DB-level constraint for a partial index, or null out the unique column in a preSoftDelete hook.
Soft delete and getReference() / DQL joins. The filter applies to DQL, not to proxy initialisation in every case, and raw ->getConnection()->executeQuery() obviously bypasses it entirely. Audit any raw SQL touching soft-deletable tables.
Uploadable leaks files. If you use the upload extension, deleting or replacing a file leaves the old object in storage unless the entity/lifecycle is configured correctly. Treat the storage bucket as eventually-consistent garbage and run a reconciliation job.
Timestampable granularity. These are \DateTime columns on this vintage of the library, not DateTimeImmutable — mutation is possible. Also check the column type: datetime truncates to the second, so ordering by createdAt on burst inserts can tie. Use datetime_immutable/datetimetz in your own override if ordering matters.
Debugging checklist when columns don't populate:
bin/console debug:container --tag=doctrine.event_subscriber — is the listener registered at all?fsi_doctrine_extensions.orm.<name>? A mismatch with doctrine.dbal.default_connection is the #1 cause.used on the concrete class (not just the parent)?deletedAt/createdAt in the flush() diff? If Doctrine sees no change, the listener never ran.bin/console cache:clear after any mapping change — stale annotation metadata is a frequent false negative.Extension points. The cleanest customization surfaces are (a) decorating the user resolver for blameable, (b) decorating the listener service itself to add a post-write hook (e.g. publish a domain event after updatedAt changes), and (c) supplying your own storage abstraction for uploadable. Forking the bundle should be a last resort; with a 3-star single-maintainer codebase, a fork is cheap but so is replacing it.
How can I help you explore Laravel packages today?