Master ObjectQuel's lifecycle callback system to execute custom business logic at specific points in an entity's persistence lifecycle, from automatic timestamps to complex validation and external system integration.
Lifecycle callbacks allow you to hook into the entity persistence process and execute custom logic at specific moments. ObjectQuel provides six lifecycle events that cover the complete entity lifecycle from creation to deletion:
<?php
namespace App\Entity;
use Quellabs\ObjectQuel\Annotations\Orm;
/**
* @Orm\Table(name="products")
* @Orm\LifecycleAware
*/
class ProductEntity {
/**
* @Orm\Column(name="created_at", type="datetime", nullable=true)
*/
private ?\DateTime $createdAt = null;
/**
* @Orm\Column(name="updated_at", type="datetime", nullable=true)
*/
private ?\DateTime $updatedAt = null;
/**
* Called before inserting a new entity
* @Orm\PrePersist
*/
public function onPrePersist(): void {
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
/**
* Called after successfully inserting a new entity
* @Orm\PostPersist
*/
public function onPostPersist(): void {
// Send notification, trigger events, etc.
$this->notifyProductCreated();
}
/**
* Called before updating an existing entity
* @Orm\PreUpdate
*/
public function onPreUpdate(): void {
$this->updatedAt = new \DateTime();
}
/**
* Called after successfully updating an entity
* @Orm\PostUpdate
*/
public function onPostUpdate(): void {
// Clear caches, sync with external systems, etc.
$this->invalidateProductCache();
}
/**
* Called before deleting an entity
* @Orm\PreDelete
*/
public function onPreDelete(): void {
// Backup data, validate deletion constraints
$this->backupProductData();
}
/**
* Called after successfully deleting an entity
* @Orm\PostDelete
*/
public function onPostDelete(): void {
// Clean up related data, notify systems
$this->cleanupProductFiles();
}
}
Key Requirements:
@Orm\LifecycleAwareExecuted before inserting a new entity into the database. Perfect for setting default values, generating UUIDs, or performing validation:
/**
* @Orm\Table(name="users")
* @Orm\LifecycleAware
*/
class UserEntity {
/**
* @Orm\Column(name="user_id", type="string", limit=36, primary_key=true)
*/
private ?string $userId = null;
/**
* @Orm\Column(name="email", type="string", limit=255)
*/
private string $email;
/**
* @Orm\Column(name="status", type="string", limit=20)
*/
private string $status = 'pending';
/**
* @Orm\Column(name="email_verification_token", type="string", limit=64, nullable=true)
*/
private ?string $emailVerificationToken = null;
/**
* @Orm\PrePersist
*/
public function generateDefaults(): void {
// Generate UUID for new users
if ($this->userId === null) {
$this->userId = $this->generateUuid();
}
// Generate email verification token
$this->emailVerificationToken = bin2hex(random_bytes(32));
// Normalize email
$this->email = strtolower(trim($this->email));
}
/**
* @Orm\PrePersist
*/
public function validateNewUser(): void {
// Custom validation before persistence
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address');
}
if (strlen($this->email) > 255) {
throw new \InvalidArgumentException('Email too long');
}
}
}
Executed after successfully inserting a new entity. Ideal for sending notifications, triggering external processes, or logging:
/**
* @Orm\Table(name="orders")
* @Orm\LifecycleAware
*/
class OrderEntity {
/**
* @Orm\Column(name="order_number", type="string", limit=20)
*/
private string $orderNumber;
/**
* @Orm\Column(name="customer_email", type="string", limit=255)
*/
private string $customerEmail;
/**
* @Orm\PostPersist
*/
public function sendOrderConfirmation(): void {
// Send confirmation email to customer
$emailService = new EmailService();
$emailService->sendOrderConfirmation($this->customerEmail, $this->orderNumber);
}
/**
* @Orm\PostPersist
*/
public function logOrderCreation(): void {
// Log order creation for analytics
$logger = new OrderLogger();
$logger->logOrderCreated($this->orderNumber, $this->customerEmail);
}
/**
* @Orm\PostPersist
*/
public function triggerInventoryUpdate(): void {
// Trigger inventory system update
$inventoryService = new InventoryService();
$inventoryService->reserveItems($this);
}
}
Executed before updating an existing entity. Useful for updating timestamps, performing change validation, or preparing data:
/**
* @Orm\Table(name="articles")
* @Orm\LifecycleAware
*/
class ArticleEntity {
/**
* @Orm\Column(name="title", type="string", limit=255)
*/
private string $title;
/**
* @Orm\Column(name="slug", type="string", limit=255)
*/
private string $slug;
/**
* @Orm\Column(name="updated_at", type="datetime", nullable=true)
*/
private ?\DateTime $updatedAt = null;
/**
* @Orm\Column(name="version", type="integer", default=1)
*/
private int $version = 1;
/**
* @Orm\PreUpdate
*/
public function updateModificationInfo(): void {
// Update timestamp
$this->updatedAt = new \DateTime();
// Increment version for optimistic locking
$this->version++;
}
/**
* @Orm\PreUpdate
*/
public function updateSlugIfNeeded(): void {
// Regenerate slug if title changed
$newSlug = $this->generateSlug($this->title);
if ($this->slug !== $newSlug) {
$this->slug = $newSlug;
}
}
/**
* @Orm\PreUpdate
*/
public function validateChanges(): void {
// Validate business rules before update
if (empty(trim($this->title))) {
throw new \InvalidArgumentException('Article title cannot be empty');
}
if (strlen($this->title) > 255) {
throw new \InvalidArgumentException('Article title too long');
}
}
}
Executed after successfully updating an entity. Perfect for cache invalidation, external system synchronization, or change tracking:
/**
* @Orm\Table(name="products")
* @Orm\LifecycleAware
*/
class ProductEntity {
/**
* @Orm\Column(name="name", type="string", limit=255)
*/
private string $name;
/**
* @Orm\Column(name="price", type="decimal", limit="10,2")
*/
private float $price;
/**
* @Orm\Column(name="status", type="string", limit=20)
*/
private string $status;
/**
* @Orm\PostUpdate
*/
public function invalidateCaches(): void {
// Clear product cache
$cacheService = new CacheService();
$cacheService->invalidate("product_{$this->getProductId()}");
$cacheService->invalidate("product_list");
}
/**
* @Orm\PostUpdate
*/
public function syncWithSearchIndex(): void {
// Update search index
$searchService = new SearchIndexService();
$searchService->updateProduct($this);
}
/**
* @Orm\PostUpdate
*/
public function trackPriceChanges(): void {
// Log price changes for historical tracking
$changeTracker = new PriceChangeTracker();
$changeTracker->trackPriceChange($this);
}
/**
* @Orm\PostUpdate
*/
public function notifySubscribers(): void {
// Notify customers watching this product
if ($this->status === 'in_stock') {
$notificationService = new NotificationService();
$notificationService->notifyStockAvailable($this);
}
}
}
Executed before deleting an entity. Essential for validation, backup creation, or dependency checking:
/**
* @Orm\Table(name="customers")
* @Orm\LifecycleAware
*/
class CustomerEntity {
/**
* @Orm\Column(name="customer_id", type="integer", primary_key=true)
*/
private ?int $customerId = null;
/**
* @Orm\OneToMany(targetEntity="OrderEntity", mappedBy="customerId")
*/
public $orders;
/**
* @Orm\PreDelete
*/
public function validateDeletion(): void {
// Prevent deletion if customer has active orders
foreach ($this->orders as $order) {
if ($order->getStatus() === 'pending' || $order->getStatus() === 'processing') {
throw new \RuntimeException('Cannot delete customer with active orders');
}
}
}
/**
* @Orm\PreDelete
*/
public function createBackup(): void {
// Create backup before deletion
$backupService = new CustomerBackupService();
$backupService->backupCustomer($this);
}
/**
* @Orm\PreDelete
*/
public function checkDependencies(): void {
// Check for critical dependencies
$dependencyChecker = new DependencyChecker();
if ($dependencyChecker->hasActiveSupportTickets($this->customerId)) {
throw new \RuntimeException('Cannot delete customer with active support tickets');
}
}
}
Executed after successfully deleting an entity. Used for cleanup, logging, or external system notifications:
/**
* @Orm\Table(name="files")
* @Orm\LifecycleAware
*/
class FileEntity {
/**
* @Orm\Column(name="filename", type="string", limit=255)
*/
private string $filename;
/**
* @Orm\Column(name="file_path", type="string", limit=500)
*/
private string $filePath;
/**
* @Orm\Column(name="file_size", type="bigint")
*/
private int $fileSize;
/**
* @Orm\PostDelete
*/
public function deletePhysicalFile(): void {
// Remove file from filesystem
if (file_exists($this->filePath)) {
unlink($this->filePath);
}
}
/**
* @Orm\PostDelete
*/
public function updateStorageMetrics(): void {
// Update storage usage statistics
$storageService = new StorageService();
$storageService->decreaseUsage($this->fileSize);
}
/**
* @Orm\PostDelete
*/
public function logFileDeletion(): void {
// Log file deletion for audit trail
$auditLogger = new AuditLogger();
$auditLogger->logFileDeletion($this->filename, $this->filePath);
}
/**
* @Orm\PostDelete
*/
public function notifyFileSystem(): void {
// Notify external file management system
$fileSystemNotifier = new FileSystemNotifier();
$fileSystemNotifier->notifyFileDeletion($this->filename);
}
}
You can define multiple callback methods for the same lifecycle event. They will be executed in the order they are defined in the class:
/**
* @Orm\Table(name="blog_posts")
* @Orm\LifecycleAware
*/
class BlogPostEntity {
/**
* @Orm\Column(name="title", type="string", limit=255)
*/
private string $title;
/**
* @Orm\Column(name="content", type="text")
*/
private string $content;
/**
* First callback - data preparation
* @Orm\PrePersist
*/
public function prepareData(): void {
$this->title = trim($this->title);
$this->content = trim($this->content);
}
/**
* Second callback - validation
* @Orm\PrePersist
*/
public function validateContent(): void {
if (empty($this->title)) {
throw new \InvalidArgumentException('Title is required');
}
if (strlen($this->content) < 100) {
throw new \InvalidArgumentException('Content must be at least 100 characters');
}
}
/**
* Third callback - additional processing
* @Orm\PrePersist
*/
public function generateMetadata(): void {
$this->slug = $this->generateSlug($this->title);
$this->readingTime = $this->calculateReadingTime($this->content);
$this->wordCount = str_word_count($this->content);
}
}
/**
* @Orm\Table(name="users")
* @Orm\LifecycleAware
*/
class UserEntity {
/**
* @Orm\Column(name="email", type="string", limit=255)
*/
private string $email;
/**
* @Orm\Column(name="status", type="string", limit=20)
*/
private string $status;
/**
* @Orm\Column(name="last_login", type="datetime", nullable=true)
*/
private ?\DateTime $lastLogin = null;
private string $previousEmail = '';
/**
* @Orm\PreUpdate
*/
public function trackEmailChanges(): void {
// Store previous email if it's changing
if ($this->email !== $this->previousEmail && !empty($this->previousEmail)) {
$emailHistoryService = new EmailHistoryService();
$emailHistoryService->recordEmailChange($this->getUserId(), $this->previousEmail, $this->email);
}
}
/**
* @Orm\PostUpdate
*/
public function handleStatusChanges(): void {
// Send welcome email only when status changes to 'active'
if ($this->status === 'active' && $this->isStatusChanged()) {
$emailService = new EmailService();
$emailService->sendWelcomeEmail($this->email);
}
// Send suspension notice when status changes to 'suspended'
if ($this->status === 'suspended' && $this->isStatusChanged()) {
$emailService = new EmailService();
$emailService->sendSuspensionNotice($this->email);
}
}
private function isStatusChanged(): bool {
// Implementation to check if status actually changed
// This could use UnitOfWork to compare with original data
return true; // Simplified for example
}
}
/**
* @Orm\Table(name="orders")
* @Orm\LifecycleAware
*/
class OrderEntity {
/**
* @Orm\Column(name="status", type="string", limit=20)
*/
private string $status;
/**
* @Orm\Column(name="total_amount", type="decimal", limit="10,2")
*/
private float $totalAmount;
/**
* @Orm\PostPersist
*/
public function processNewOrder(): void {
// Use dependency injection or service locator
$serviceContainer = ServiceContainer::getInstance();
// Process payment
$paymentService = $serviceContainer->get(PaymentService::class);
$paymentService->processOrderPayment($this);
// Update inventory
$inventoryService = $serviceContainer->get(InventoryService::class);
$inventoryService->reserveOrderItems($this);
// Send notifications
$notificationService = $serviceContainer->get(NotificationService::class);
$notificationService->sendOrderCreatedNotifications($this);
}
/**
* @Orm\PostUpdate
*/
public function handleStatusUpdates(): void {
$serviceContainer = ServiceContainer::getInstance();
switch ($this->status) {
case 'shipped':
$shippingService = $serviceContainer->get(ShippingService::class);
$shippingService->generateTrackingInfo($this);
break;
case 'delivered':
$loyaltyService = $serviceContainer->get(LoyaltyService::class);
$loyaltyService->awardLoyaltyPoints($this);
break;
case 'cancelled':
$inventoryService = $serviceContainer->get(InventoryService::class);
$inventoryService->releaseOrderItems($this);
break;
}
}
}
/**
* @Orm\Table(name="products")
* @Orm\LifecycleAware
*/
class ProductEntity {
/**
* @Orm\PostUpdate
*/
public function updateExternalSystems(): void {
try {
// Critical operation - should fail the transaction
$inventoryService = new InventoryService();
$inventoryService->updateStock($this);
} catch (InventoryException $e) {
// Re-throw critical exceptions to fail the transaction
throw new \RuntimeException('Failed to update inventory: ' . $e->getMessage(), 0, $e);
}
try {
// Non-critical operation - should not fail the transaction
$searchIndexService = new SearchIndexService();
$searchIndexService->updateProduct($this);
} catch (SearchIndexException $e) {
// Log error but don't fail the transaction
$logger = new Logger();
$logger->error('Failed to update search index for product ' . $this->getProductId(), [
'exception' => $e,
'product_id' => $this->getProductId()
]);
}
}
/**
* @Orm\PreDelete
*/
public function validateDeletionSafety(): void {
try {
// Check if product can be safely deleted
$dependencyChecker = new ProductDependencyChecker();
$dependencyChecker->checkCanDelete($this);
} catch (DependencyException $e) {
// Prevent deletion by throwing exception
throw new \RuntimeException('Cannot delete product: ' . $e->getMessage(), 0, $e);
}
}
}
/**
* @Orm\Table(name="analytics_events")
* @Orm\LifecycleAware
*/
class AnalyticsEventEntity {
/**
* @Orm\PostPersist
*/
public function optimizedLogging(): void {
// Good: Lightweight operation
$this->logEventQuickly();
}
/**
* @Orm\PostPersist
*/
public function asyncProcessing(): void {
// Good: Queue heavy operations for background processing
$queueService = new QueueService();
$queueService->queueAnalyticsProcessing($this->getEventId());
}
/**
* Don't do this in callbacks - too slow
*/
public function badCallback(): void {
// Bad: Expensive operations that slow down persistence
// $this->generateComplexReport(); // Takes 2+ seconds
// $this->sendMultipleEmails(); // Network I/O
// $this->processLargeDataSet(); // Memory intensive
// $this->callSlowExternalAPI(); // Unreliable network
}
private function logEventQuickly(): void {
// Fast, local logging
error_log("Event {$this->getEventId()} created");
}
}
/**
* @Orm\Table(name="orders")
* @Orm\LifecycleAware
*/
class OrderEntity {
private static array $pendingNotifications = [];
/**
* @Orm\PostPersist
*/
public function queueNotification(): void {
// Collect notifications for batch processing
self::$pendingNotifications[] = $this->getOrderId();
}
/**
* Process all queued notifications (called after flush)
*/
public static function processPendingNotifications(): void {
if (!empty(self::$pendingNotifications)) {
$notificationService = new NotificationService();
$notificationService->sendBatchNotifications(self::$pendingNotifications);
self::$pendingNotifications = [];
}
}
}
// In your service layer after flush():
$entityManager->flush();
OrderEntity::processPendingNotifications();
Do:
Don't:
ObjectQuel's lifecycle callback system provides a powerful way to implement cross-cutting concerns like auditing, validation, and system integration while maintaining clean separation between business logic and persistence concerns.