Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Doctrine Behaviors Laravel Package

netbull/doctrine-behaviors

Doctrine behavior extensions for Laravel, adding reusable traits and listeners for common ORM patterns like timestamps, slugs, soft deletes, and blameable fields, so models stay clean and consistent with minimal boilerplate.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a Doctrine ORM package, not an Eloquent one. Before anything else, confirm that your Laravel app actually runs Doctrine — usually via laravel-doctrine/orm (EntityManagerInterface bound in the container). If you're on Eloquent, stop here and use cviebrock/eloquent-sluggable, spatie/laravel-activitylog, spatie/laravel-translatable, Knuckles\.../SoftDeletes instead; there is no adapter.

If you are on Doctrine:

composer require netbull/doctrine-behaviors

Where to look first. This is a maintained fork of knplabs/doctrine-behaviors (it inherits the API and, importantly, the namespaces may differ). Before writing code, run:

find vendor/netbull/doctrine-behaviors/src -name '*Listener.php' -o -name '*Trait.php' | sort

You'll see the layout: Model/<Behavior>/<Behavior>Trait.php and ORM/<Behavior>/<Behavior>Listener.php (Knp-compatible forks use the same shape). Pin the version in composer.json — with 0 dependents you don't want a surprise break.

Register the listeners. This is the step everyone forgets; nothing happens without it. There is no Laravel auto-discovery for Doctrine event subscribers. Register in a service provider:

// app/Providers/DoctrineBehaviorsServiceProvider.php
use Doctrine\ORM\EntityManagerInterface;
use Netbull\DoctrineBehaviors\ORM\Timestampable\TimestampableListener;
use Netbull\DoctrineBehaviors\ORM\Sluggable\SluggableListener;

public function boot(EntityManagerInterface $em): void
{
    $events = $em->getEventManager();
    $events->addEventSubscriber(new TimestampableListener());
    $events->addEventSubscriber(new SluggableListener());
}

(If you use LaravelDoctrine's config/doctrine.php, you can also list subscribers under managers.default.events.subscribers, but instantiating in a provider gives you a place to inject the user resolver and locale — see below.)

First use case — a Post with timestamps and a slug:

namespace App\Entities;

use Doctrine\ORM\Mapping as ORM;
use Netbull\DoctrineBehaviors\Model\Timestampable\TimestampableTrait;
use Netbull\DoctrineBehaviors\Model\Sluggable\SluggableTrait;
use Netbull\DoctrineBehaviors\Model\Sluggable\Sluggable; // interface

#[ORM\Entity]
#[ORM\Table(name: 'posts')]
class Post implements Sluggable
{
    use TimestampableTrait;
    use SluggableTrait;

    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 255)]
    private string $title = '';

    public function getSluggableFields(): array
    {
        return ['title'];
    }

    public function setTitle(string $title): void
    {
        $this->title = $title;
        $this->slug = null; // force regeneration on title change
    }
}

Then $em->persist($post); $em->flush(); and you get createdAt, updatedAt, and slug populated. Run php artisan doctrine:schema:update --force (or generate a migration) so the columns exist.

Implementation Patterns

Wire the context-dependent listeners explicitly. Three behaviors can't work from a bare new XListener():

// Blameable — needs an authenticated user
$blameable = new BlameableListener();
$blameable->setUserCallable(fn () => Auth::user() ?: null);
$em->getEventManager()->addEventSubscriber($blameable);

// Translatable — needs the current locale
$translatable = new TranslatableListener($em);
$translatable->setCurrentLocale(App::getLocale());
$em->getEventManager()->addEventSubscriber($translatable);

// Loggable — needs user + a Log entity
$loggable = new LoggableListener();
$loggable->setUser(Auth::user());
$loggable->setLogger(...); // your PSR logger

For the translatable locale, register a small middleware that calls setCurrentLocale() on the listener when App::setLocale() changes, and — critically — do it again in each queue job.

Translatable entities are two entities. The "translatable" (Post) holds the locale-independent fields, and the "translation" (PostTranslation) holds the localizable ones. Both use their respective traits, both are registered as entities, and the translatable side must declare a cascade so translations get persisted:

#[ORM\OneToMany(targetEntity: PostTranslation::class, mappedBy: 'translatable', cascade: ['persist', 'remove'])]
protected $translations;

Day-to-day you don't touch the collection:

$post->translate('en')->setTitle('Hello');
$post->translate('fr')->setTitle('Bonjour');
$em->persist($post);   // translations cascade
$em->flush();

translate($locale, $fallback = true) transparently creates the translation row if missing (and marks it as "new" for the listener to pick up). Read with $post->translate('fr')->getTitle(); when the requested locale is missing and the fallback is on, you get the default-locale value rather than null.

Sluggable is driven by methods on your entity, not config. Override to control behavior:

public function getSluggableFields(): array        // ['title', 'subtitle']
public function getSlugSeparator(): string         // '-'
public function shouldGenerateSlug(): bool         // e.g. only when slug is empty
public function getRegenerateSlugOnUpdate(): bool  // default false — slug is stable

The shouldGenerateSlug()/getRegenerateSlugOnUpdate() pair is the workhorse: set them so editing a published article's title doesn't silently break every inbound URL. Then expose a "regenerate slug" admin action that sets $post->setSlug(null) and flushes.

SoftDeletable is a query filter, not just a column. After mapping the trait you enable it once:

$em->getFilters()->enable('soft_deletable'); // check the exact filter name in the package source

After that $em->remove($post); $em->flush(); issues an UPDATE ... SET deleted_at = ..., and normal find()/DQL calls exclude it. For a trash view:

$filter = $em->getFilters()->getFilter('soft_deletable');
$filter->disableForEntity(Post::class);
$deleted = $em->getRepository(Post::class)->findBy(['deletedAt' => /* not null */]);
$filter->enableForEntity(Post::class); // always re-enable in a finally-ish flow

Backfilling existing tables. Adding Sluggable/Uuidable to a table with rows means the listener only fires for newly managed entities. Write a one-shot command that iterates existing records inside a batch, sets the source field, and flushes in chunks with the listener registered — then add the NOT NULL constraint in a follow-up migration.

Composition order in the trait block. Traits share fields like id only if the package defines them (it usually doesn't — you keep your own id). Keep use order consistent across entities so diffs stay readable, and put the behavior traits after any id declaration to avoid "trait property conflict" errors.

Gotchas and Tips

  • "I added the trait and nothing happened." 95% of the time the listener isn't registered on that EntityManager. Verify with $em->getEventManager()->getListeners('prePersist') and ->getListeners('onFlush') — behavior traits never do work by themselves.
  • Fork namespace drift. Because this is a fork, use Knp\DoctrineBehaviors\... snippets from the upstream README won't autoload. Copy the namespace straight from the files you found with find. Also composer require will fail-fast if both knplabs/doctrine-behaviors and this package are present — remove one.
  • Bulk DQL and QueryBuilder::update() bypass listeners. UPDATE post SET title = ... will not touch updated_at or regenerate a slug. Keep behavior-managed writes going through the UnitOfWork, or set those columns explicitly in the DQL.
  • Soft delete collides with unique indexes. A soft-deleted row still occupies slug (or uuid) in the DB. Either make the unique index composite ((slug, deleted_at)), or blank the slug on delete, or you'll get integrity violations on re-create with the same title.
  • Filter state leaks across requests/tests. The soft_deletable filter is global to the EntityManager, and disableForEntity() persists across your test suite and across Octane requests. Reset filters in tearDown()/setUp(), and never leave a disable unpaired.
  • Long-lived workers. TranslatableListener::setCurrentLocale() and BlameableListener::setUser() are mutable singletons. In a queue worker or Octane, a locale or user from a previous job will bleed into the next one. Set both explicitly at the top of every job and middleware invocation.
  • Blameable in console/queue has no user. createdBy will be null and, if the column is NOT NULL, flush() throws a DB error at persist time. Always make blameable columns nullable, and consider a system sentinel user for jobs.
  • Loggable recursion. The log entity itself is managed; if it's not excluded from the listener you get an infinite change-set loop on flush. Check for a setLoggedClass/exclusion API in the listener before enabling Loggable globally.
  • Translatable + hydration. $post->translate('fr') on a detached or partially-hydrated entity can trigger an extra query per call — N+1 across a listing. Join-fetch translations on the entities you actually render, and skip the translate() accessor in list views.
  • Tree behavior's children collection is lazy. Rendering a 3-level menu via getChildren() recursion is textbook N+1. Preload with a join-fetch or a recursive CTE, and set OrderBy on the children mapping so menu output is deterministic.
  • Timestampable's updatedAt only moves when Doctrine detects a change. Changing only the inverse side of a relation doesn't produce a change set, so no preUpdate. Touch a scalar field (or use $em->getUnitOfWork()->recomputeSingleEntityChangeSet()) when you need the timestamp to move.
  • Testing. Use doctrine/doctrine-fixtures with the listeners registered on the test EntityManager; otherwise fixtures produce rows without slugs/timestamps and assertions start failing in confusing places. SQLite in-memory is fine but re-check the filter behavior — the filter is applied in DQL, so it works the same.
  • Extension points. The listeners are meant to be subclassed: override getSluggableFields() handling, slugification (getSlugSeparator/transliteration), or the onFlush scheduling. Keep your subclass in app/Doctrine/ and register that, not the vendor class, so upgrading the package doesn't silently reset your customization.
  • Low adoption signal. 0 stars / 0 dependents is not a code-quality verdict, but it does mean you're the first line of support. Wrap the behaviors behind your own repository methods and entity setters so a future swap to upstream knplabs/doctrine-behaviors (or hand-rolled prePersist listeners) is a small diff rather than a rewrite.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle