fsi/doctrine-extensions
Doctrine extensions for Laravel that add common ORM behaviors such as soft deletes, timestamps, sluggable, tree, and sortable mappings, helping you reuse familiar persistence patterns with Doctrine entities.
1. Confirm the fit before installing. fsi/doctrine-extensions is a Doctrine ORM library, not an Eloquent one. If your Laravel app is Eloquent-first, stop here. It only pays off in an app where you have (or are introducing) a Doctrine EntityManager — typically via laravel-doctrine/orm, which gives you a ManagerRegistry binding and doctrine.* config.
composer require fsi/doctrine-extensions
# you almost certainly also need:
composer require laravel-doctrine/orm doctrine/orm:^2.14
2. Where to look first. Read the package src/ tree, not the README — it's the least maintained artefact. The two things worth your time:
DoctrineExtensions/Uploadable/ — the interesting half: File, FileInterface, FileWriterInterface, FileReaderInterface, UploadableListener, and the annotation classes used in entity mapping.DoctrineExtensions/Timestampable/ — the thin half: CreatedAt/UpdatedAt annotations plus a listener that just sets new \DateTime() on prePersist/preUpdate.There is no Laravel bundle here. The sibling fsi/doctrine-extensions-bundle (Symfony) is still the best documentation for how the listeners must be wired — copy its listener registration and config shape into a service provider.
3. Register the listeners. In a dedicated provider, resolve the EM and attach listeners in boot():
public function boot(ManagerRegistry $registry)
{
$em = $registry->getManager();
$evm = $em->getEventManager();
$evm->addEventSubscriber(new TimestampableListener());
$evm->addEventSubscriber(new UploadableListener(
storage_path('app/doctrine-uploads'), // directory the writer uses
false // overwrite?
));
}
4. First use case — pick the timestamp one. Annotate createdAt/updatedAt on an entity, flush(), and confirm the values populate. That validates your event wiring with zero filesystem risk. Once timestamps work, move to Uploadable, which is where the real value (and real complexity) lives.
Timestamps belong to the model, not the controller. The whole point is deleting $entity->setUpdatedAt(new \DateTime()) from every action and repository method. Let the listener own it:
/** @ORM\Entity */
class Invoice
{
/** @ORM\Column(type="datetime") @Timestampable\CreatedAt */
private \DateTime $createdAt;
/** @ORM\Column(type="datetime", nullable=true) @Timestampable\UpdatedAt */
private ?\DateTime $updatedAt = null;
}
If you use PHP 8 attributes rather than annotations, check whether the bundled annotation classes are #[Attribute]-decorated — with a 2021 release they likely are not, so you'll stay on annotations or write a tiny mapping shim.
Uploadable — the day-to-day workflow. The pattern is: the entity stores a File value object, the listener writes bytes to disk in postPersist/postUpdate and removes them in postRemove. Typical flow in an upload action:
$file = new \FSi\Component\Filesystem\File\File($request->file('avatar')->getRealPath());
$file->setName($request->file('avatar')->getClientOriginalName());
$user->setAvatar($file); // plain setter on a @Uploadable property
$em->persist($user);
$em->flush(); // listener writes the file + stores the name
Prefer injecting a FileWriterInterface implementation backed by Laravel's Storage facade over the concrete writer, so tests can use a fake and production can use a disk — this is the package's main extension point.
Wrap listeners in your own subscriber. Don't scatter addEventSubscriber calls. One DoctrineExtensionsSubscriber (or a single provider method) that registers all of them keeps ordering explicit and makes it obvious in code review that flush behaviour is being modified globally.
Soft-delete pattern. If you use the soft-delete listener, pair it with a Doctrine SQLFilter and enable it on the EM:
$em->getFilters()->enable('soft-deleteable');
With Laravel's laravel-doctrine the manager is often scoped, so enabling the filter in a middleware or a request-scoped provider is safer than doing it once at boot — otherwise long-running queues and Octane workers keep stale state.
Testing pattern. Listeners only fire through the Unit of Work. Use flush() + $em->clear() and assert against a fresh fetch; a mock EntityManager proves nothing. For uploads, point the writer at a temp dir and delete it in tearDown.
Migrations. This package assumes doctrine/migrations. If the app also uses Eloquent migrations, decide per-bounded-context: Doctrine entities get Doctrine migrations, Eloquent models get Laravel's. Mixing them in one schema is the fastest route to drift.
ClassMetadata, EventManager, and several listener signatures changed. Pin doctrine/orm:^2.14 and verify PHP 8.2+ compatibility before committing — the annotation reader chain (doctrine/annotations) is also deprecated in favour of attributes.updatedAt doesn't fire when nothing changed. Doctrine only computes a change set for modified fields; preUpdate won't run on a no-op flush. Don't write tests that assume otherwise, and don't use it as a proxy for "this request touched the row".datetime, so timezone is whatever date_default_timezone_set says at flush time. Standardise on UTC in AppServiceProvider and store datetime_immutable where you can — the listener's \DateTime default fights you slightly, so override or normalise.postPersist, which is inside the flush boundary. If your surrounding transaction rolls back, you get orphan files on disk with no DB row pointing at them. Either move uploads outside the transaction or run a reconciliation job keyed on a UUID you generate before persisting.File property does not delete the file. Removal only happens on postRemove. Explicitly delete through the listener/FileWriter when a user clears an avatar.php artisan doctrine:clear-metadata-cache (or cache:clear) — otherwise loadClassMetadata never re-runs and the File column mapping silently stays stale.$em->getEventManager()->getListeners('preUpdate') — a listener that "isn't working" is usually a listener that was attached to a different EntityManager instance than the one your controller resolved.onFlush subscriber; timestamps should be applied before your changelog logic reads the change set.$em->getConnection()->executeQuery() in the codebase.FileWriterInterface / FileReaderInterface (swap the storage backend), MimeTypeGuesser (uploads validation), and subclassing UploadableListener to override property resolution if your naming conventions differ from the defaults.created_at/updated_at on Eloquent, Storage::put for files, and a deleted_at global scope cover 90% of what this package does — with community support. Choose it because you're already on Doctrine, not to avoid writing five lines.How can I help you explore Laravel packages today?