The framework wraps Doctrine's lifecycle callback system into typed PHP classes rather than methods directly on the entity. Each event gets its own class with a single callback() method, which keeps entities lean and makes each callback independently testable.
Every entity extends AbstractEntity, which extends DoctrineCallbacksLoader. That class registers Doctrine lifecycle hooks (#[ORM\PrePersist], #[ORM\PostUpdate], etc.) and delegates them to callback classes you define. The entity declares which callback class handles which event via getLifecycleCallbacks().
The flow for any lifecycle event:
Doctrine fires event (e.g. postRemove)
└── DoctrineCallbacksLoader::__postRemove()
└── getCallbackClass( Events::postRemove, $args )
└── new UserPreRemoveCallback( $this, $args )
└── callback() ← your logic runs here
If no callback is registered for an event, getCallbackClass() returns null and nothing happens.
All eight standard Doctrine lifecycle events are supported:
| Constant | Fires when |
|---|---|
Events::prePersist |
Before a new entity is inserted |
Events::postPersist |
After a new entity is inserted |
Events::preUpdate |
Before an existing entity is updated |
Events::postUpdate |
After an existing entity is updated |
Events::preRemove |
Before an entity is deleted |
Events::postRemove |
After an entity is deleted |
Events::preFlush |
At the very start of a flush operation |
Events::postLoad |
After an entity is loaded from the database |
Implement getLifecycleCallbacks() in your entity, returning an array that maps Doctrine event constants to callback class names:
// App/Entity/User.php
public function getLifecycleCallbacks(): array
{
return [
Events::preRemove => UserPreRemoveCallback::class,
Events::postUpdate => UserPostUpdateCallback::class,
];
}
Only register events you actually need. Events with no entry in this array are ignored at zero cost.
Create a class in App/DoctrineLifecycleCallbacks/ extending AbstractDoctrineLifecycleCallback:
// App/DoctrineLifecycleCallbacks/UserPreRemoveCallback.php
namespace App\DoctrineLifecycleCallbacks;
use App\Entity\User;
use PHP_SF\System\Classes\Abstracts\AbstractDoctrineLifecycleCallback;
/**
* @property User $entity
* @property \Doctrine\ORM\Event\PreRemoveEventArgs $args
*/
class UserPreRemoveCallback extends AbstractDoctrineLifecycleCallback
{
public function callback(): void
{
// $this->entity is the User being removed
// $this->args is the Doctrine event args
// Example: clean up related data before deletion
rca()->delete( sprintf( 'user_profile:%d', $this->entity->getId() ) );
}
}
The @property docblocks are optional but give you IDE autocompletion for $this->entity and $this->args since the base class types them as AbstractEntity and EventArgs respectively.
The constructor is handled by AbstractDoctrineLifecycleCallback — you don't need to define one. Both $this->entity and $this->args are available inside callback().
Inside callback():
// The entity that triggered the event
$this->entity; // typed as AbstractEntity, cast to your entity via @property docblock
// The Doctrine event arguments object
$this->args; // typed as EventArgs, specific subtype depends on the event
// Common event arg types per event:
// PreRemoveEventArgs → preRemove
// PostRemoveEventArgs → postRemove
// PrePersistEventArgs → prePersist
// PostPersistEventArgs → postPersist
// PreUpdateEventArgs → preUpdate (contains getEntityChangeSet())
// PostUpdateEventArgs → postUpdate
// PreFlushEventArgs → preFlush
// PostLoadEventArgs → postLoad
For preUpdate specifically, $this->args gives you access to getEntityChangeSet() which returns the old and new values for every changed field — useful for audit logging:
/**
* @property User $entity
* @property \Doctrine\ORM\Event\PreUpdateEventArgs $args
*/
class UserPreUpdateCallback extends AbstractDoctrineLifecycleCallback
{
public function callback(): void
{
$changes = $this->args->getEntityChangeSet();
// $changes['email'] = [ 'old@example.com', 'new@example.com' ]
if ( array_key_exists( 'email', $changes ) ) {
// log email change, send notification, etc.
}
}
}
postPersist, postUpdate, and postRemove automatically call static::clearQueryBuilderCache() before your callback runs. This is handled by DoctrineCallbacksLoader and happens regardless of whether you've registered a callback for those events. It deletes all Redis keys matching *doctrine_result_cache:* to prevent stale query results after any write.
You don't need to call it manually in your callbacks — it's already done.
// App/Entity/Post.php
use App\DoctrineLifecycleCallbacks\PostPrePersistCallback;
use App\DoctrineLifecycleCallbacks\PostPostUpdateCallback;
use Doctrine\ORM\Events;
class Post extends AbstractEntity
{
// ... properties, getters, setters
public function getLifecycleCallbacks(): array
{
return [
Events::prePersist => PostPrePersistCallback::class,
Events::postUpdate => PostPostUpdateCallback::class,
];
}
}
// App/DoctrineLifecycleCallbacks/PostPrePersistCallback.php
/**
* @property Post $entity
* @property \Doctrine\ORM\Event\PrePersistEventArgs $args
*/
class PostPrePersistCallback extends AbstractDoctrineLifecycleCallback
{
public function callback(): void
{
// Set a slug before the post is first saved
$this->entity->setSlug(
strtolower( str_replace( ' ', '-', $this->entity->getTitle() ) )
);
}
}
// App/DoctrineLifecycleCallbacks/PostPostUpdateCallback.php
/**
* @property Post $entity
* @property \Doctrine\ORM\Event\PostUpdateEventArgs $args
*/
class PostPostUpdateCallback extends AbstractDoctrineLifecycleCallback
{
public function callback(): void
{
// Invalidate the cached rendered post after any update
rca()->delete( sprintf( 'rendered_post:%d', $this->entity->getId() ) );
}
}
There's no enforced naming convention, but the pattern used throughout the project is:
{EntityName}{EventName}Callback
Examples: UserPreRemoveCallback, PostPostUpdateCallback, OrderPrePersistCallback.
Place all callback classes in App/DoctrineLifecycleCallbacks/.
Returning an empty array from getLifecycleCallbacks() — this is valid and means the entity has no custom callbacks. The method has a concrete default on DoctrineCallbacksLoader that returns [], so entities are not required to override it; override it only when you need to register callbacks.
Registering an event that doesn't exist in AVAILABLE_CALLBACKS — DoctrineCallbacksLoader silently ignores any event key that isn't one of the eight supported events. If a callback isn't firing, verify the event constant is spelled correctly.
Doing heavy work in prePersist or preUpdate — these fire inside Doctrine's transaction. Slow operations (external API calls, large cache operations) will hold the transaction open. Move heavy work to the post variants where possible.
Calling em('connection_name')->flush() inside a callback — this will cause an infinite loop for persist/update callbacks since flushing triggers the same lifecycle events again. If you need to persist related entities, use em('connection_name')->persist() only and let the outer flush handle it.