Defining entities

SkillDev tools

Reference for defining Doctrine entities in OrangeHRM — the docblock annotation conventions, table-name conventions (`ohrm_` for new tables, `hs_hr_` for legacy 4.x-imported tables), column types and options, ID strategies, relations (ManyToOne / OneToMany / ManyToMany with `mappedBy` / `inversedBy` / `cascade` / `JoinColumn` / `JoinTable`), `ArrayCollection` initialization, `@ORM\EntityListeners` for lifecycle (the only lifecycle pattern this codebase uses — not `@HasLifecycleCallbacks`), the project-specific Decorator pattern (`DecoratorTrait` + sibling `<Name>Decorator` class), and the `NestedSet` helper for tree-shaped entities. Use whenever the user is creating a new entity, adding columns or relations to an existing one, adding an EntityListener (typically for encrypting sensitive columns), writing a Decorator, working with a tree entity (Subunit pattern), or debugging "why is this field null after load" / "why does this relation not lazy-load." Companion to `doctrine-bootstrap` (how Doctrine is wired) and `daos` (querying entities).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Defining entities skill

What this skill tells your AI

The instructions your AI receives, as published by orangehrm/orangehrm in .agents/skills/entities/SKILL.md and read by ahel’s review.

All entities live in the flat OrangeHRM\Entity\ namespace regardless of which plugin owns them. Each plugin contributes its own entity/ directory to the multi-path namespace — see the doctrine-bootstrap skill for the registration mechanics.

This skill covers everything from "annotate the class" to "wire up a Decorator." For querying these entities, see the daos skill. For DDL (creating the underlying table), see the migrations skill.

The class shell

<?php
namespace OrangeHRM\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="ohrm_widget")
 * @ORM\Entity
 */
class Widget
{
    // …
}

Five rules followed everywhere:

  1. Namespace is OrangeHRM\Entity, never a plugin-specific sub-namespace. The flat namespace is a hard constraint — the framework discovers entities across all plugin entity/ directories via PSR-4.
  2. Class-level annotations are @ORM\Entity and @ORM\Table(name="…"). @ORM\Entity is required; without it Doctrine ignores the class.
  3. Docblock annotations only — no PHP 8 attributes. The bootstrap uses createAnnotationMetadataConfiguration (see doctrine-bootstrap skill). Mixing attributes silently breaks discovery.
  4. use Doctrine\ORM\Mapping as ORM; at the top of every entity file.
  5. No business logic. Entities are plain getters/setters + ORM mapping. Convenience methods that need DB access go on a sibling Decorator class (see below).

Table naming convention

  • New tables: ohrm_<resource> (e.g. ohrm_widget, ohrm_data_group, ohrm_user_role, ohrm_api_permission).
  • Legacy tables: hs_hr_<resource> (e.g. hs_hr_employee, hs_hr_emp_basicsalary, hs_hr_config). These were imported from the 4.x codebase via the V3_3_3 baseline migration. Don't rename them — too many SQL queries, FKs, and external integrations depend on the exact names.

When adding a new table for a new feature, use the ohrm_ prefix. The hs_hr_ prefix is historical only.

Columns

/**
 * @var string|null
 *
 * @ORM\Column(name="job_description", type="string", length=400, nullable=true)
 */
private ?string $jobDescription = null;

Conventions seen everywhere:

  • @var docblock matches the typed property declaration. Always include both.
  • name="…" — the DB column name. Often snake_case while the PHP property is camelCase.
  • type="…" — Doctrine type constant. Common ones: integer, string, text, boolean, smallint, bigint, decimal, date, datetime, datetimetz, time, json, blob.
  • length=… — required for string; for performance and to mirror the DB column. Common defaults: 100 for names, 255 for free text fields, 512 for ciphertext storage.
  • nullable=true when the column allows null. Always pair with ?type and = null default in the property declaration.
  • options={"default" : …} — column default at the DB level. Use sparingly; prefer PHP-side defaults in the constructor when possible.

Reserved-word column names

Backtick-quote them in the annotation:

/**
 * @ORM\Column(name="`key`", type="string", length=100)
 */
private string $key;

key, order, value, status — MySQL reserves these. The V5_0_0_beta migration renamed hs_hr_config.key to name precisely to avoid the issue ongoing.

JSON columns

Doctrine has a built-in json type that handles serialization/deserialization:

/**
 * @ORM\Column(name="additional_params", type="text", nullable=true,
 *             options={"comment" = "(DC2Type:json)"})
 */
private ?array $additionalParams = null;

The (DC2Type:json) marker in the comment is Doctrine's convention — it's how the schema tool round-trips the type when introspecting an existing column declared as TEXT. New JSON columns should use type="json" directly; the comment marker only appears on legacy columns that started as TEXT.

Legacy ENUM columns

Columns declared ENUM(...) in MySQL load as plain strings. The bootstrap registers enum → string at the platform level (see doctrine-bootstrap). Don't try to declare type="enum" — Doctrine has no such type. Pattern:

public const STATE_ACTIVE     = 'ACTIVE';
public const STATE_TERMINATED = 'TERMINATED';

/**
 * @ORM\Column(name="state", type="string", length=20)
 */
private string $state = self::STATE_ACTIVE;

Class constants document the allowed values; validators (Rules::IN) gate input; the column itself is just a string at the ORM level.

IDs

The codebase has one ID pattern, used by every entity:

/**
 * @var int
 *
 * @ORM\Column(name="id", type="integer", length=4)
 * @ORM\Id
 * @ORM\GeneratedValue(strategy="AUTO")
 */
private int $id;

strategy="AUTO" on MySQL becomes AUTO_INCREMENT. Don't use IDENTITY, SEQUENCE, UUID, or composite keys without a strong reason — the rest of the codebase assumes integer surrogate keys.

The property name is not always id — it follows the domain. Employee uses $empNumber (column emp_number), but UserRole uses $id. Whatever the property is, getters follow it (getEmpNumber(), getId()). Other entities reference the ID via that property name, e.g. @JoinColumn(name="employee_id", referencedColumnName="emp_number").

Unique constraints and indexes

Declared at the table level:

/**
 * @ORM\Table(
 *     name="ohrm_api_permission",
 *     uniqueConstraints={
 *         @ORM\UniqueConstraint(name="api_name", columns={"api_name"})
 *     }
 * )
 * @ORM\Entity
 */
class ApiPermission { … }

Multi-column unique constraints follow the same shape — list all columns in the columns={…} array. @ORM\Index works analogously for non-unique indexes (rare in this codebase; most indexes are added via migrations directly).

Constructor — initialize collections

Every collection-valued relation needs an ArrayCollection initialized in the constructor:

public function __construct()
{
    $this->dependents          = new ArrayCollection();
    $this->emergencyContacts   = new ArrayCollection();
    $this->skills              = new ArrayCollection();
    // … one per OneToMany / ManyToMany on the class
}

If you skip this, the relation is null until lazy-loaded — and code that adds to it before fetching from the DB will throw Call to a member function add() on null. The Employee entity initializes ~17 collections in its constructor; mirror that for any entity with many relations.

Relations

The four flavors all appear in the codebase. Reference Employee (Pim plugin) for the canonical examples.

ManyToOne — the most common

The "many" side owns the foreign key:

/**
 * @var JobTitle|null
 *
 * @ORM\ManyToOne(targetEntity="OrangeHRM\Entity\JobTitle", inversedBy="employees")
 * @ORM\JoinColumn(name="job_title_code", referencedColumnName="id", nullable=true)
 */
private ?JobTitle $jobTitle = null;
  • targetEntity is the related entity's FQCN.
  • inversedBy="employees" is optional but conventional — points at the OneToMany collection on the other side. Doctrine uses this to keep both sides in sync when you set one.
  • @JoinColumn names the FK column. referencedColumnName defaults to id if omitted — be explicit when the target's ID column is non-standard (e.g. emp_number).
  • nullable=true when the relation is optional. Always pair with ?Type and = null default.

OneToMany — the inverse of ManyToOne

The collection side. Always paired with a ManyToOne on the other entity.

/**
 * @var Employee[]
 *
 * @ORM\OneToMany(targetEntity="OrangeHRM\Entity\Employee", mappedBy="jobTitle")
 */
private iterable $employees;
  • mappedBy="jobTitle" — names the property on the other entity that owns the FK. Required.
  • Property type is iterable in annotations + initialized to ArrayCollection in the constructor.
  • @var docblock uses array notation (Employee[]) for static analysis hints.

ManyToMany — needs a JoinTable

Owning side declares the join table:

/**
 * @var Location[]
 *
 * @ORM\ManyToMany(targetEntity="OrangeHRM\Entity\Location", inversedBy="employees")
 * @ORM\JoinTable(
 *     name="hs_hr_emp_locations",
 *     joinColumns={@ORM\JoinColumn(name="emp_number", referencedColumnName="emp_number")},
 *     inverseJoinColumns={@ORM\JoinColumn(name="location_id", referencedColumnName="id")}
 * )
 */
private iterable $locations;

Inverse side just uses mappedBy:

/**
 * @ORM\ManyToMany(targetEntity="OrangeHRM\Entity\Employee", mappedBy="locations")
 */
private iterable $employees;

OneToOne

/**
 * @var EmpPicture|null
 *
 * @ORM\OneToOne(targetEntity="OrangeHRM\Entity\EmpPicture", mappedBy="employee")
 */
private ?EmpPicture $empPicture = null;

Or on the owning side (with @JoinColumn):

/**
 * @ORM\OneToOne(targetEntity="OrangeHRM\Entity\JobSpecificationAttachment", mappedBy="jobTitle",
 *               cascade={"persist", "remove"})
 */
private ?JobSpecificationAttachment $jobSpecificationAttachment = null;

Self-referential M:N (Employee.supervisors)

Same class on both sides. The owning side declares the join table; the inverse uses mappedBy:

// Owning side
@ORM\ManyToMany(targetEntity="OrangeHRM\Entity\Employee", inversedBy="subordinates")
@ORM\JoinTable(name="hs_hr_emp_reportto", joinColumns=…, inverseJoinColumns=…)
private iterable $supervisors;

// Inverse side
@ORM\ManyToMany(targetEntity="OrangeHRM\Entity\Employee", mappedBy="supervisors")
private iterable $subordinates;

cascade and orphanRemoval

Used sparingly. cascade={"persist", "remove"} is the most common pair, mostly on OneToOne where the child is logically owned by the parent (JobSpecificationAttachment → JobTitle). Most relations have no cascade — services explicitly manage related-entity lifecycle.

orphanRemoval=true exists but is rare in this codebase. Don't add cascades reflexively; only when the related entity has no meaning outside the parent.

Entity lifecycle — @ORM\EntityListeners, not lifecycle callbacks

The codebase does NOT use @HasLifecycleCallbacks / @PrePersist / @PreUpdate on the entity class itself. Zero usages. The lifecycle pattern is always external listener classes registered via @ORM\EntityListeners.

/**
 * @ORM\Table(name="hs_hr_employee")
 * @ORM\Entity
 * @ORM\EntityListeners({"OrangeHRM\Entity\Listener\EmployeeListener"})
 */
class Employee { … }

The listener class:

namespace OrangeHRM\Entity\Listener;

use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Event\LifecycleEventArgs;
use OrangeHRM\Entity\Employee;

class EmployeeListener extends BaseListener
{
    public function preUpdate(Employee $employee, PreUpdateEventArgs $eventArgs): void
    {
        if ($this->encryptionEnabled() && $eventArgs->hasChangedField('ssnNumber')) {
            $employee->setSsnNumber(
                $this->getCryptographer()->encrypt($employee->getSsnNumber())
            );
        }
    }

    public function postUpdate(Employee $employee, LifecycleEventArgs $eventArgs): void
    {
        if ($this->encryptionEnabled()) {
            $employee->setSsnNumber(
                $this->getCryptographer()->decrypt($employee->getSsnNumber())
            );
        }
    }
}

Three real listeners exist: EmployeeListener (SSN encryption), EmployeeSalaryListener (basic-salary encryption), EmailConfigurationListener (SMTP password encryption).

BaseListener (src/plugins/orangehrmCorePlugin/entity/Listener/BaseListener.php) is a thin abstract:

abstract class BaseListener
{
    use EncryptionHelperTrait;  // gives $this->encryptionEnabled(), $this->getCryptographer()
}

Why external listeners and not @HasLifecycleCallbacks? Because the lifecycle logic needs a service (Cryptographer) that itself depends on config. Listeners can pull services in via traits; lifecycle callbacks on the entity itself can't easily, and they couple business logic to the data shape.

The pattern: encrypt on preUpdate (and prePersist if needed), decrypt on postUpdate (and postLoad) so the in-memory entity stays readable. Symmetric round-trip.

The Decorator pattern — entity-adjacent logic with DB access

Entities are plain POPOs. Logic that involves the EntityManager — looking up a related entity by ID, formatting a date with the user's timezone, computing the employee's STATE from termination + active flags — lives on a sibling Decorator class.

The trait

OrangeHRM\Entity\Decorator\DecoratorTrait is used on the entity:

namespace OrangeHRM\Entity;

use OrangeHRM\Entity\Decorator\DecoratorTrait;
use OrangeHRM\Entity\Decorator\EmployeeDecorator;

/**
 * @method EmployeeDecorator getDecorator()    ← IDE hint for the typed return
 *
 * @ORM\Table(name="hs_hr_employee")
 * @ORM\Entity
 */
class Employee
{
    use DecoratorTrait;
    // …
}

The trait gives you a single method:

public function getDecorator(): object
{
    if (is_null($this->entityDecorator)) {
        $decoratorClassName = $this->getDecoratorClassName();
        $this->entityDecorator = new $decoratorClassName($this);
    }
    return $this->entityDecorator;
}

It resolves the Decorator class name by reflection: OrangeHRM\Entity\Decorator\<EntityName>Decorator. Override getDecoratorClassName() if you need something different (rarely needed).

The @method PHPDoc on the entity tells IDEs the precise return type — without it, IDEs see object and lose autocomplete.

The Decorator class

namespace OrangeHRM\Entity\Decorator;

use OrangeHRM\Core\Traits\ORM\EntityManagerHelperTrait;
use OrangeHRM\Core\Traits\Service\DateTimeHelperTrait;
use OrangeHRM\Entity\Employee;
use OrangeHRM\Entity\JobTitle;

class EmployeeDecorator
{
    use EntityManagerHelperTrait;
    use DateTimeHelperTrait;

    public function __construct(private Employee $employee) {}

    public function setJobTitleById(?int $id): void
    {
        $jobTitle = is_null($id) ? null : $this->getReference(JobTitle::class, $id);
        $this->employee->setJobTitle($jobTitle);
    }

    public function getJoinedDate(): ?string {
        $date = $this->employee->getJoinedDate();
        return is_null($date) ? null : $this->getDateTimeHelper()->formatDate($date);
    }

    public function getState(): string { /* compute from termination + status */ }

    public function getFullName(): string { /* compose first + middle + last */ }
}

The Decorator is where you put:

  • setXById($id) shortcuts — fetch the related entity via getReference() and call the real setter. Used heavily during API request body binding so callers don't have to fetch FK targets themselves.
  • Formatters that need DI — date formatters (need user timezone), translators, etc.
  • Computed propertiesgetState() (Active / Terminated / Not Exist), getFullName(), etc.

When to use a Decorator vs. a Service

  • Decorator if the logic is intrinsic to the entity and naturally written as "operations on this object." Employee->getFullName(). The Decorator gives you the entity in $this->employee.
  • Service if the logic involves multiple unrelated entities, complex workflow, or external integrations. EmployeeService::transferToLocation($empNumber, $locationId) belongs in a Service, not a Decorator.

Decorators are widely used — 11+ in PimPlugin alone. The pattern is well-established; mirror it for any new entity that has both DB-touching helpers and a need to stay POPO.

Tree-shaped entities — the NestedSet helper

For traditional MPTT (nested set) trees, src/lib/orm/NestedSet/ provides:

  • NestedSetInterface, NodeInterface — contracts
  • NestedSetTraitlft/rgt/treeId accessors
  • Node — a value object

One real consumer: Subunit (the org structure tree, orangehrmAdminPlugin/entity/Subunit.php).

class Subunit implements NestedSetInterface
{
    use NestedSetTrait;
    // … usual entity stuff, plus lft/rgt/depth columns
}

If you need a hierarchy with frequent "all descendants of X" queries, NestedSet is the existing pattern. For simple parent/child without deep traversal, a regular self-referential ManyToOne is fine and lower-friction.

What's NOT used (don't propose these)

  • PHP 8 attributes for mapping. Doctrine supports them, but the bootstrap uses createAnnotationMetadataConfiguration so only docblock annotations are scanned. A partial migration silently breaks the migrated entities. (The doctrine-bootstrap skill anchors the exact Doctrine version to src/composer.json.)
  • @HasLifecycleCallbacks / @PrePersist / @PreUpdate / etc. on the entity class itself. Use @ORM\EntityListeners instead — every existing example uses the external listener pattern.
  • Custom repositoryClass on @Entity. The DAO layer (see daos skill) replaces Doctrine's repository-per-entity feature; entities don't reference a repository class.
  • Embedded objects (@Embedded). Not used.
  • Inheritance mapping (@InheritanceType, @DiscriminatorColumn). Not used. The few class hierarchies in the codebase (e.g. report definitions) use separate tables and separate entities, not single-table or joined-table inheritance.
  • UUID IDs. Every entity uses integer AUTO_INCREMENT.

Recipes

Recipe 1 — A simple entity with one M2O relation

<?php
namespace OrangeHRM\Entity;

use Doctrine\ORM\Mapping as ORM;
use OrangeHRM\Entity\Decorator\DecoratorTrait;
use OrangeHRM\Entity\Decorator\WidgetDecorator;

/**
 * @method WidgetDecorator getDecorator()
 *
 * @ORM\Table(name="ohrm_widget")
 * @ORM\Entity
 */
class Widget
{
    use DecoratorTrait;

    /**
     * @var int
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private int $id;

    /**
     * @var string
     * @ORM\Column(name="name", type="string", length=100)
     */
    private string $name;

    /**
     * @var Employee|null
     * @ORM\ManyToOne(targetEntity="OrangeHRM\Entity\Employee")
     * @ORM\JoinColumn(name="owner_emp_number", referencedColumnName="emp_number", nullable=true)
     */
    private ?Employee $owner = null;

    // getId, getName/setName, getOwner/setOwner …
}

Migration creates the table (see migrations skill). DAO queries it (see daos skill).

Recipe 2 — Entity with one-to-many + initialized collection

/**
 * @ORM\Table(name="ohrm_widget_category")
 * @ORM\Entity
 */
class WidgetCategory
{
    // … id, name …

    /**
     * @var Widget[]
     * @ORM\OneToMany(targetEntity="OrangeHRM\Entity\Widget", mappedBy="category")
     */
    private iterable $widgets;

    public function __construct()
    {
        $this->widgets = new ArrayCollection();
    }

    public function getWidgets(): iterable { return $this->widgets; }
}

The matching Widget adds:

/**
 * @ORM\ManyToOne(targetEntity="OrangeHRM\Entity\WidgetCategory", inversedBy="widgets")
 * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
 */
private ?WidgetCategory $category = null;

Recipe 3 — Decorator class with setXById shortcuts

namespace OrangeHRM\Entity\Decorator;

use OrangeHRM\Core\Traits\ORM\EntityManagerHelperTrait;
use OrangeHRM\Entity\Employee;
use OrangeHRM\Entity\Widget;
use OrangeHRM\Entity\WidgetCategory;

class WidgetDecorator
{
    use EntityManagerHelperTrait;

    public function __construct(private Widget $widget) {}

    public function setOwnerByEmpNumber(?int $empNumber): void
    {
        $employee = is_null($empNumber) ? null : $this->getReference(Employee::class, $empNumber);
        $this->widget->setOwner($employee);
    }

    public function setCategoryById(?int $id): void
    {
        $category = is_null($id) ? null : $this->getReference(WidgetCategory::class, $id);
        $this->widget->setCategory($category);
    }
}

API handler then uses these shortcuts to bind from request body without fetching FK targets first:

$widget->getDecorator()->setCategoryById($categoryId);
$widget->getDecorator()->setOwnerByEmpNumber($ownerEmpNumber);

getReference() (from EntityManagerHelperTrait) returns a proxy — no SELECT until the related entity's data is accessed. Cheap for the FK-binding case.

Recipe 4 — EntityListener for encrypting a sensitive field

// Entity
/**
 * @ORM\EntityListeners({"OrangeHRM\Entity\Listener\OAuthClientListener"})
 */
class OAuthClient { /* clientSecret column … */ }

// Listener
namespace OrangeHRM\Entity\Listener;

use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use OrangeHRM\Entity\OAuthClient;

class OAuthClientListener extends BaseListener
{
    public function prePersist(OAuthClient $client, LifecycleEventArgs $args): void
    {
        if ($this->encryptionEnabled()) {
            $client->setClientSecret($this->getCryptographer()->encrypt($client->getClientSecret()));
        }
    }

    public function preUpdate(OAuthClient $client, PreUpdateEventArgs $args): void
    {
        if ($this->encryptionEnabled() && $args->hasChangedField('clientSecret')) {
            $client->setClientSecret($this->getCryptographer()->encrypt($client->getClientSecret()));
        }
    }

    public function postLoad(OAuthClient $client, LifecycleEventArgs $args): void
    {
        if ($this->encryptionEnabled()) {
            $client->setClientSecret($this->getCryptographer()->decrypt($client->getClientSecret()));
        }
    }

    public function postUpdate(OAuthClient $client, LifecycleEventArgs $args): void
    {
        if ($this->encryptionEnabled()) {
            $client->setClientSecret($this->getCryptographer()->decrypt($client->getClientSecret()));
        }
    }
}

The column type stays string with widened length (≥512) to accommodate ciphertext. The migration that introduces the field should size it correctly from the start.


Checklists

Add a new entity

  • File at src/plugins/orangehrm{X}Plugin/entity/<Name>.php in namespace OrangeHRM\Entity;
  • @ORM\Entity + @ORM\Table(name="ohrm_<resource>") on class
  • Plugin's entity dir is in OrangeHRM\Entity\ PSR-4 in src/composer.json (see doctrine-bootstrap skill)
  • composer dump-autoload -d src after touching composer.json
  • Integer AUTO_INCREMENT ID — @ORM\Id + @ORM\GeneratedValue(strategy="AUTO")
  • All collection-valued relations initialized to new ArrayCollection() in __construct
  • Getters/setters for every persisted property + each relation
  • If logic needs DB access: sibling Decorator class in Entity\Decorator\ + use DecoratorTrait + @method PHPDoc hint
  • If lifecycle hooks needed: @ORM\EntityListeners({"…"}) + listener class extending BaseListener
  • Migration to create the table (see migrations skill)

Add a relation between entities

  • Decide owning side — the one with the FK column. M2O ↔ O2M: owning is the M2O side. M2M: arbitrary, but JoinTable lives there.
  • Owning side: @ManyToOne / @OneToOne / @ManyToMany + @JoinColumn (or @JoinTable for M2M) + optional inversedBy="…"
  • Inverse side: @OneToMany / @OneToOne / @ManyToMany + mappedBy="…"
  • Inverse side collection: initialize in constructor
  • nullable=true on @JoinColumn paired with ?Type and = null if relation is optional
  • Add a Decorator method setXById($id) if FK will be set from a body param
  • Schema migration (see migrations skill)

Add an EntityListener

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
1k
Forks
749
Last commit
Jun 2026
Advanced
Catalog kind
skill
Gateway key
entities
Source
github.com/orangehrm/orangehrm