Skip to content

Entity lifecycle hooks

Small Swoole Entity Manager provides optional, interface-based lifecycle callbacks. An entity opts in by implementing one or more interfaces from Small\SwooleEntityManager\Entity\Contract. The manager invokes the corresponding methods automatically; application code does not need to call them, register listeners or dispatch events.

Version availability

Automatic OnNewInterface and OnLoadInterface dispatch was added to Core master in this change. Confirm that the version installed through Composer contains this change. Earlier published Core 3 versions may expose the interfaces without automatically invoking these two hooks. The persistence callbacks below already existed before that change.

All supported callbacks

Each interface is independent. Implement only the callbacks your entity needs. All callback methods have a void return type.

Interface Method Automatically invoked
OnNewInterface onNew(): void Once during a normal $manager->newEntity(), after manager defaults have been applied
OnLoadInterface onLoad(): void After database-backed entity hydration (dbValue: true), including the original-primary-key snapshot
BeforePersistInterface beforePersist(): void Before either an insert or an update initiated by persist()
BeforeInsertInterface beforeInsert(): void Immediately before the insert
AfterInsertInterface afterInsert(): void After a successful insert, once the entity has been marked as persisted
BeforeUpdateInterface beforeUpdate(): void Immediately before updating an existing entity
AfterUpdateInterface afterUpdate(): void After a successful update
AfterPersistInterface afterPersist(): void After the insert/update flow completes
BeforeDeleteInterface beforeDelete(): void Immediately before deleting the entity
AfterDeleteInterface afterDelete(): void After a successful delete

Execution order

newEntity()
  -> apply manager field defaults
  -> onNew()                           [only if implemented]

persist() on a new entity
  -> beforePersist()
  -> beforeInsert()
  -> INSERT
  -> afterInsert()
  -> afterPersist()

Load from the database
  -> create entity without onNew()
  -> hydrate database fields/relations
  -> record original primary keys
  -> onLoad()                          [only if implemented]

persist() on an existing entity
  -> beforePersist()
  -> beforeUpdate()
  -> UPDATE
  -> afterUpdate()
  -> afterPersist()

delete()
  -> beforeDelete()
  -> DELETE
  -> afterDelete()

The insert or update path is chosen using the entity's persisted state (fromDb). The beforePersist() / afterPersist() pair surrounds both paths. Delete does not execute persist hooks.

Callbacks are synchronous in the manager's execution path. An exception in a callback propagates; it does not automatically provide a database transaction or rollback.

Complete example: initialization, hydration and persistence

This example uses an application-owned identifier so the initialization hook can assign it automatically. The transient loadedFromDatabase property is intentionally not a database field.

<?php

declare(strict_types=1);

namespace App\Entity;

use Small\SwooleEntityManager\Entity\AbstractEntity;
use Small\SwooleEntityManager\Entity\Attribute\Field;
use Small\SwooleEntityManager\Entity\Attribute\NoLastInsertId;
use Small\SwooleEntityManager\Entity\Attribute\OrmEntity;
use Small\SwooleEntityManager\Entity\Attribute\PrimaryKey;
use Small\SwooleEntityManager\Entity\Contract\BeforePersistInterface;
use Small\SwooleEntityManager\Entity\Contract\OnLoadInterface;
use Small\SwooleEntityManager\Entity\Contract\OnNewInterface;
use Small\SwooleEntityManager\Entity\Enum\FieldValueType;

#[OrmEntity]
#[NoLastInsertId]
final class Note extends AbstractEntity implements
    OnNewInterface,
    OnLoadInterface,
    BeforePersistInterface
{
    #[PrimaryKey]
    private ?string $id = null;

    #[Field(type: FieldValueType::string)]
    private ?string $title = null;

    #[Field(type: FieldValueType::dateTime)]
    private ?\DateTime $createdAt = null;

    #[Field(type: FieldValueType::dateTime)]
    private ?\DateTime $updatedAt = null;

    private bool $loadedFromDatabase = false;

    public function onNew(): void
    {
        $this->id = bin2hex(random_bytes(16));
        $this->createdAt = new \DateTime();
    }

    public function onLoad(): void
    {
        $this->loadedFromDatabase = true;
    }

    public function beforePersist(): void
    {
        $this->updatedAt = new \DateTime();
    }

    public function getId(): ?string
    {
        return $this->id;
    }

    public function wasLoadedFromDatabase(): bool
    {
        return $this->loadedFromDatabase;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }
}

For example, with a NoteManager configured for Note::class:

/** @var \App\Entity\Note $note */
$note = $noteManager->newEntity();      // onNew() runs once
$note->setTitle('Example')->persist();  // beforePersist(), then INSERT

/** @var \App\Entity\Note $loaded */
$loaded = $noteManager->findOneBy(['id' => $note->getId()]);
// Database hydration has called onLoad() on $loaded.
assert($loaded->wasLoadedFromDatabase());

Do not call $note->onNew() manually. The callback's signature must remain void, as required by OnNewInterface; fluent setters can still be chained on the entity returned by newEntity().

Creation versus hydration

The manager deliberately uses different paths to avoid resetting identifiers and creation timestamps when reading existing rows.

  • newEntity(): applies the manager's default field values, then invokes onNew() if implemented.
  • makeEntityFromStdClass($data) or makeEntityFromArray($data) with the default dbValue: false: maps input data without invoking onNew() or onLoad().
  • makeEntityFromStdClass($data, dbValue: true) and its array counterpart: create the entity without onNew(), convert database values, hydrate fields/relations and finally invoke onLoad().
  • Standard database reads through manager query results use the database hydration path, so an entity implementing OnLoadInterface is notified without an explicit controller call.

newEntity(initialize: false) is available for internal hydration paths. Normal application code should use newEntity() instead.

Keep lifecycle methods focused

onNew() is suitable for application-generated IDs, initial timestamps and initial field values. onLoad() is suitable for transient, non-persisted state derived from a loaded entity. Avoid unbounded side effects, database writes or explicitly calling other lifecycle methods from these hooks. In particular, calling onNew() on a loaded object can overwrite existing values.

Persistence hooks

Implement any combination of BeforePersistInterface, BeforeInsertInterface, BeforeUpdateInterface, AfterInsertInterface, AfterUpdateInterface and AfterPersistInterface. No registration is necessary.

use Small\SwooleEntityManager\Entity\Contract\BeforeInsertInterface;
use Small\SwooleEntityManager\Entity\Contract\BeforeUpdateInterface;

final class ExampleEntity extends AbstractEntity implements
    BeforeInsertInterface,
    BeforeUpdateInterface
{
    // ORM fields and the other entity members are omitted for brevity.

    public function beforeInsert(): void
    {
        // Prepare values required only on the first insert.
    }

    public function beforeUpdate(): void
    {
        // Prepare values required only before an update.
    }
}

The Before* methods execute before their database operation; After* methods execute after it completes normally. Exceptions are not silently swallowed. Use explicit transaction handling when an operation requires atomicity across multiple database actions.

For deletion, BeforeDeleteInterface and AfterDeleteInterface are invoked by the entity deletion path, independently of insert/update and persist hooks.

Bulk update and delete operations

Set-based builders execute directly against the database by default. They do not hydrate entities or dispatch per-entity callbacks.

$manager->executeUpdate($updateBuilder); // Bulk SQL, hooks skipped
$manager->executeDelete($deleteBuilder); // Bulk SQL, hooks skipped

To invoke callbacks for matching entities, opt into the entity-by-entity execution path:

$manager->executeUpdate($updateBuilder, skipTriggers: false);
$manager->executeDelete($deleteBuilder, skipTriggers: false);

This can be substantially slower for large result sets because the manager selects and processes matching entities individually. Use it when hook behavior is required; otherwise use the native bulk path. See Update builder and Delete builder.

Next chapter: Swoole runtime and connection drivers