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. All entities must implement this method since it's defined on the DoctrineCallbacksLoaderInterface, so an empty array is the correct return value when no callbacks are needed.
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.