Lifecycle Callbacks

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.

Understanding Lifecycle Callbacks

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:

  • Entity must be annotated with @Orm\LifecycleAware
  • Callback methods must be annotated with the appropriate lifecycle annotation
  • Callback methods should not require parameters
  • Callback methods should not return values

Available Lifecycle Events

@Orm\PrePersist - Before Entity Creation

Executed 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');
        }
    }
}

@Orm\PostPersist - After Entity Creation

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);
    }
}

@Orm\PreUpdate - Before Entity Modification

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');
        }
    }
}

@Orm\PostUpdate - After Entity Modification

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);
        }
    }
}

@Orm\PreDelete - Before Entity Removal

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');
        }
    }
}

@Orm\PostDelete - After Entity Removal

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);
    }
}

Multiple Callbacks per Event

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);
    }
}

Advanced Lifecycle Callback Patterns

Conditional Logic in Callbacks

/**
 * @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
    }
}

Service Integration in Callbacks

/**
 * @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;
        }
    }
}

Error Handling in Lifecycle Callbacks

Exception Handling Strategy

/**
 * @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);
        }
    }
}

Performance Considerations

Efficient Callback Implementation

/**
 * @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");
    }
}

Batch Processing Optimization

/**
 * @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();

Lifecycle Callbacks Best Practices

Do:

  • Keep callbacks lightweight and fast
  • Use callbacks for data validation and integrity
  • Handle timestamps and auto-generated values
  • Queue heavy operations for background processing
  • Log important events for auditing
  • Validate business rules consistently

Don't:

  • Perform expensive I/O operations in callbacks
  • Make unreliable external API calls
  • Execute long-running computations
  • Access uninitialized related entities
  • Ignore exceptions that should fail transactions
  • Create circular dependencies between entities

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.