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-awaretable: 'movies'maps the class to themoviestabledatabase: 'cinema'ties the entity to the named data source#[PrimaryGeneratedColumn]says the primary key is generated by the database#[Column(...)]marks ordinary persisted fieldsChangeRecorderTraitgives 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
VARCHARfor short strings - use
TEXTfor longer free-form content - use
BOOLEANfor 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
Entitysuffix. - 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: