vasyaxy/doctrine-behaviors
vasyaxy/doctrine-behaviors provides reusable Doctrine behavior support for Laravel and PHP applications, making it easier to add common model behaviors and configuration without repetitive boilerplate. A lightweight option for projects already using Doctrine ORM.
Reality check before you start: this is a Doctrine ORM package, not an Eloquent one. There is no ServiceProvider integration with Laravel's default ORM. To use it day-to-day you must already be running Doctrine inside Laravel (typically laravel-doctrine/orm + laravel-doctrine/extensions), which is a minority setup. If your app is Eloquent-first, this package is not applicable — look at spatie/laravel-sluggable, spatie/laravel-translatable, spatie/laravel-activitylog, and SoftDeletes instead.
Install (Doctrine projects only):
composer require vasyaxy/doctrine-behaviors
Where to look first, in this order:
vendor/vasyaxy/doctrine-behaviors/src/ — the trait namespaces (Timestampable, Sluggable, SoftDeletable, Uuidable, Translatable, Tree, Blameable, Loggable).src/EventListener/ — which behaviors are driven by a listener/subscriber (Sluggable, Blameable, SoftDeletable, Translatable, Tree, Loggable) versus pure lifecycle callbacks (Timestampable, most Uuidable).tests/ in the repo — the fastest working reference for the correct trait + mapping + listener combination.composer.json — check for a extra.laravel.providers block. As of the last release there is none, so registration is manual.First use case — a timestamped, sluggable Post:
namespace App\Entities;
use Doctrine\ORM\Mapping as ORM;
use Vasyaxy\DoctrineBehaviors\Timestampable\TimestampableTrait;
use Vasyaxy\DoctrineBehaviors\Sluggable\SluggableTrait;
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks] // REQUIRED for timestampable to fire
class Post
{
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;
// SluggableTrait expects this shape:
public function getSluggableFields(): array
{
return ['title'];
}
}
Then register the slug listener in a service provider's boot() (see next section). Verify with $em->getConnection()->getConfiguration()->setSQLLogger(...) or Laravel Debugbar that slug is populated on persist().
Central listener registration. Because nothing auto-wires, add a small provider that attaches every behavior listener you use to the EntityManager once, on boot:
public function boot(EntityManagerInterface $em)
{
$evm = $em->getEventManager();
$evm->addEventSubscriber(new SluggableListener());
$evm->addEventSubscriber(new SoftDeleteableListener());
$evm->addEventSubscriber(new TreeListener());
// Blameable/Translatable need the listener instance shared globally
// so you can call setUser() / setCurrentLocale() later.
}
Bind the BlameableListener and TranslatableListener as singletons in the container — you will need to mutate them per request.
Blameable with Laravel auth. Doctrine has no auth() helper. Push the user into the listener from a middleware or a RequestHandled hook:
public function handle($request, Closure $next)
{
if ($user = $request->user()) {
app(BlameableListener::class)->setUser($user->getAuthIdentifier());
}
return $next($request);
}
SoftDeletable filter management. The filter must be enabled on the EntityManager before querying; it is not enabled by default:
$em->getFilters()->enable('soft-deleteable')
->setParameter('soft_deleteable.deleted_at', 'deletedAt');
// admin view
$em->getFilters()->disable('soft-deleteable');
Translatable with Laravel's locale. Keep a single TranslatableListener and set the locale wherever App::setLocale() runs:
app(TranslatableListener::class)->setCurrentLocale(app()->getLocale());
app(TranslatableListener::class)->setDefaultLocale(config('app.fallback_locale'));
Then use $post->translate('fr')->getTitle() or $post->getTranslations() for edit forms. Translation entities must extend AbstractTranslation and carry their own ORM\Entity mapping.
Tree. Use the provided repository ($em->getRepository(Category::class)) rather than hand-writing parent assignments — node moves must go through persistAsFirstChildOf() / persistAsNextSiblingOf() or the nested-set left/right values desynchronise.
Loggable. You must create your own LogEntry entity extending AbstractLogEntry and annotate entities with #[ORM\Entity] #[ORM\Table] plus the Loggable trait — the listener will not invent a table for you.
Testing pattern. Build a fixture entity in tests and assert on the trait side effects (e.g. $post->getCreatedAt() not null after flush()), because the traits mostly mutate state rather than exposing rich APIs.
KnpLabs/DoctrineBehaviors (and gedmo/doctrine-extensions) rather than an actively maintained library. Diff the source against those upstreams before relying on it; if they are equivalent, depend on upstream directly.#[ORM\HasLifecycleCallbacks] is silently required. Without it, TimestampableTrait hooks never fire and your created_at stays NULL — no error, just empty columns. Same class of bug for any trait using @PrePersist/@PreUpdate.getConnection()->executeQuery(), and some bulk DQL bypass the filter. Deleted rows will resurface in raw reports. Also remember to disable the filter when you want to restore.null (or a thrown exception depending on listener version). Explicitly setUser() in queued jobs and Artisan commands, or guard with if (app()->runningInConsole()).UniqueConstraintViolationException, then retry. Also decide whether slugs should change on title edits — changing them breaks SEO and stable URLs, so freeze the slug after first generation.GeneratedValue is replaced by client-side UUID generation. Ensure your column is guid/CHAR(36) (or binary if configured), and stop relying on ID ordering — use createdAt for chronology.->leftJoin('p.translations', 't') or fetch-join hints. Missing translations return empty proxies, so check getTitle() ?? null rather than assuming a value.$node->setParent($other) and flush by hand; use the repository's move methods. After any suspected corruption, run a tree-verification/rebuild routine.laravel-doctrine logs SQL through Monolog, not Laravel's query log. Enable doctrine.dbal.logging and doctrine.dbal.profiling in config, or attach a SQLLogger, to see what the listeners actually emit on flush.generateSlug(), getTimestampableFields(), getUser()). Prefer overriding in the entity over forking the package — much easier to migrate back to upstream later.doctrine.orm.auto_generate_proxy_classes=0 plus the metadata/query cache means listener or trait changes require php artisan doctrine:clear-metadata-cache.How can I help you explore Laravel packages today?