Entities

An entity is the PHP class that describes how one kind of record is stored.

If you strip the idea down to its essentials, an entity answers these questions:

  • which table or collection does this class map to?
  • which property is the primary key?
  • which properties become persisted columns?
  • which properties represent relations to other entities?

In Assegai projects, entity class names should use the Entity suffix. That keeps persistence types easy to recognize at a glance.

A first entity

Here is a small but realistic example:

<?php

namespace Assegaiphp\CinemaHub\Movies\Entities;

use Assegai\Orm\Attributes\Columns\Column;
use Assegai\Orm\Attributes\Columns\PrimaryGeneratedColumn;
use Assegai\Orm\Attributes\Entity;
use Assegai\Orm\Queries\Sql\ColumnType;
use Assegai\Orm\Traits\ChangeRecorderTrait;

#[Entity(
  table: 'movies',
  database: 'cinema',
)]
class MovieEntity
{
  use ChangeRecorderTrait;

  #[PrimaryGeneratedColumn]
  public ?int $id = null;

  #[Column(type: ColumnType::VARCHAR, nullable: false)]
  public string $title = '';

  #[Column(type: ColumnType::TEXT, nullable: true)]
  public ?string $synopsis = null;

  #[Column(type: ColumnType::BOOLEAN, nullable: false)]
  public bool $isNowShowing = false;
}

What each part is doing:

  • #[Entity(...)] marks the class as persistence-aware
  • table: 'movies' maps the class to the movies table
  • database: 'cinema' ties the entity to the named data source
  • #[PrimaryGeneratedColumn] says the primary key is generated by the database
  • #[Column(...)] marks ordinary persisted fields
  • ChangeRecorderTrait gives the entity common lifecycle fields such as created/updated tracking

What this maps to in SQL

A SQLite-flavoured table for MovieEntity would look roughly like this:

CREATE TABLE movies (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title VARCHAR(255) NOT NULL,
  synopsis TEXT NULL,
  is_now_showing BOOLEAN NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  deleted_at DATETIME NULL
);

The exact generated-key syntax changes by driver, but the row shape and column naming stay the same.

Entity property SQL column Why it exists
$id id generated primary key
$title title regular persisted string
$synopsis synopsis nullable text column
$isNowShowing is_now_showing regular persisted boolean
$createdAt created_at added by ChangeRecorderTrait
$updatedAt updated_at added by ChangeRecorderTrait
$deletedAt deleted_at added by ChangeRecorderTrait

That last group is worth noticing. By default, ordinary properties become snake_case columns unless you override them explicitly, and ChangeRecorderTrait follows that same database-friendly style for its lifecycle fields.

Entity versus DTO

This distinction matters a lot.

A DTO describes input at an application boundary. An entity describes persisted state.

For example:

<?php

final readonly class CreateMovieDTO
{
  public function __construct(
    public string $title,
    public ?string $synopsis,
  ) {
  }
}

That DTO might be used for request validation or CLI input. The entity is different because it needs persistence-oriented concerns such as:

  • primary keys
  • relation properties
  • persistence traits
  • stored scalar formats

Keeping DTOs and entities separate reduces confusion and avoids leaking storage details into transport code.

Choosing column types

The goal is not to model every possible nuance on day one. The goal is to make persistence obvious and stable.

A good starting approach is:

  • use VARCHAR for short strings
  • use TEXT for longer free-form content
  • use BOOLEAN for true/false flags
  • use scalar values in the database even if the domain uses richer PHP types elsewhere

That last point is especially important with enums.

Using enums safely

If the domain has fixed states, a PHP enum is usually clearer than scattered string literals.

<?php

namespace Assegaiphp\CinemaHub\Movies\Enums;

enum MovieRating: string
{
  case G = 'g';
  case PG = 'pg';
  case PG13 = 'pg13';
  case R = 'r';
}

Store the enum's backed value in the entity:

<?php

use Assegai\Orm\Attributes\Columns\Column;
use Assegai\Orm\Queries\Sql\ColumnType;
use Assegaiphp\CinemaHub\Movies\Enums\MovieRating;

#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $rating = MovieRating::PG->value;

Why this is a good pattern:

  • the database stores plain strings
  • migrations stay simple
  • querying stays predictable
  • PHP code still gets a clear enum at the service boundary when needed

Creating entities through the repository

Most app code should not instantiate a half-populated entity by hand. The repository can map plain input into the entity shape for you.

<?php

$movie = $moviesRepository->create([
  'title' => 'Harbor Lights',
  'synopsis' => 'A projectionist discovers a lost reel that changes each time it is played.',
  'isNowShowing' => true,
]);

Then persist it:

<?php

$saveResult = $moviesRepository->save($movie);

if ($saveResult->isError()) {
  throw $saveResult->getLatestError();
}

This two-step flow is useful because it gives you a moment to apply domain defaults before the write happens.

Entity-level driver overrides

Most entities should inherit their data source from the module or app. But if one entity truly belongs elsewhere, you can be explicit:

<?php

use Assegai\Orm\Enumerations\DataSourceType;

#[Entity(
  table: 'audit_logs',
  database: 'analytics',
  driver: DataSourceType::POSTGRESQL,
)]
class AuditLogEntity
{
}

Use this sparingly. It is most valuable when one class genuinely belongs to a different store.

Habits that keep entities healthy

  • Name them with the Entity suffix.
  • Keep them close to persisted shape.
  • Keep DTO validation and transport concerns out of them.
  • Store enum backed values, not enum objects.
  • Put relation metadata on them, but load relation data explicitly at query time.

Next steps

Once the entity model feels clear, continue with:

  1. Relations
  2. Migrations
  3. Working with Entity Manager