Use this guide once you have a resource and want to persist real data.
It covers the everyday ORM work:
- modeling entities
- injecting repositories
- reading and writing records
- understanding the result objects that come back
Start from a generated resource
assegai g r posts gives you a practical starting shape:
src/Posts/
├── DTOs/
│ ├── CreatePostDTO.php
│ └── UpdatePostDTO.php
├── Entities/
│ └── PostEntity.php
├── PostsController.php
├── PostsModule.php
└── PostsService.php
That structure already separates the main concerns cleanly:
- DTOs shape request data
- the entity shapes persistence
- the service uses the repository
- the controller handles HTTP
Model an entity deliberately
The generated entity gives you an id. The next step is to define the real columns:
<?php
namespace Assegaiphp\BlogApi\Posts\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: 'posts',
database: 'blog',
)]
class PostEntity
{
use ChangeRecorderTrait;
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $title = '';
#[Column(type: ColumnType::TEXT, nullable: false)]
public string $body = '';
#[Column(type: ColumnType::BOOLEAN, nullable: false)]
public bool $isPublished = false;
}
Two habits are worth keeping:
- put request validation in DTOs, not in entity classes
- keep the entity close to the actual table shape
Use enums when the data has a fixed set of states
Enums are a good fit for columns such as:
- order status
- user role
- payment provider
- publication state
Start with a PHP enum:
<?php
namespace Assegaiphp\BlogApi\Posts\Enums;
enum PostStatus: string
{
case DRAFT = 'draft';
case REVIEW = 'review';
case PUBLISHED = 'published';
}
Then store the enum's backed value in the entity column:
<?php
namespace Assegaiphp\BlogApi\Posts\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;
use Assegaiphp\BlogApi\Posts\Enums\PostStatus;
#[Entity(table: 'posts', database: 'blog')]
class PostEntity
{
use ChangeRecorderTrait;
#[PrimaryGeneratedColumn]
public ?int $id = null;
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $title = '';
#[Column(type: ColumnType::VARCHAR, nullable: false)]
public string $status = PostStatus::DRAFT->value;
}
That is the safest current pattern because the database stores plain strings, which are easy to query and easy to migrate.
In your service or DTO mapping code, convert between the enum and the stored value explicitly:
<?php
use Assegaiphp\BlogApi\Posts\Enums\PostStatus;
public function publish(int $id): void
{
$result = $this->postsRepository->update(
['id' => $id],
(object) ['status' => PostStatus::PUBLISHED->value],
);
if ($result->isError()) {
throw new RuntimeException('Failed to publish post.', previous: $result->getErrors()[0]);
}
}
public function getStatusLabel(array $post): string
{
return PostStatus::from($post['status'])->name;
}
Two practical rules help here:
- keep the entity column as the backed scalar value, not the enum object itself
- keep enum conversion close to your service or DTO boundary so the persistence format stays obvious
Inject the repository into the service
<?php
namespace Assegaiphp\BlogApi\Posts;
use Assegai\Core\Attributes\Injectable;
use Assegai\Orm\Attributes\InjectRepository;
use Assegai\Orm\Management\Repository;
use Assegaiphp\BlogApi\Posts\Entities\PostEntity;
#[Injectable]
class PostsService
{
public function __construct(
#[InjectRepository(PostEntity::class)]
private Repository $postsRepository,
) {
}
}
That repository is the main API for day-to-day data access.
Create records
create() can build an entity-shaped object directly from a DTO or any other plain PHP object. For most feature code, save() is the best default write path.
<?php
use Assegaiphp\BlogApi\Posts\DTOs\CreatePostDTO;
use RuntimeException;
public function create(CreatePostDTO $dto): object
{
$post = $this->postsRepository->create($dto);
$post->isPublished = false;
$saveResult = $this->postsRepository->save($post);
if ($saveResult->isError()) {
throw new RuntimeException('Failed to create post.', previous: $saveResult->getErrors()[0]);
}
return $post;
}
That is usually the most natural service code in Assegai apps:
- pass the DTO straight into
create() - set any extra fields the DTO should not control directly
- persist the entity with
save()
insert() is still available, but save() is the smoother day-to-day choice for most use cases because it keeps the write path consistent as relations and entity state become more involved.
If you are inserting an entity graph that includes owner-side relations, prefer save() with InsertOptions. The relation guide covers that in detail.
Read records
The most common query entry points are:
find()for a listfindOne()for one recordfindBy()for simple where clausescount()for totalsfindAndCount()when you want entities plus a total
Example service methods:
<?php
public function findAll(): array
{
return $this->postsRepository->find([
'where' => ['isPublished' => true],
'order' => ['id' => 'DESC'],
'skip' => 0,
'limit' => 20,
])->getData();
}
public function findById(int $id): object
{
return $this->postsRepository->findOne([
'where' => ['id' => $id],
])->getFirst();
}
public function countPublished(): int
{
return $this->postsRepository->count([
'where' => ['isPublished' => true],
]);
}
Update records
Use update() when you already know the criteria. In most apps, you can pass the DTO itself instead of tediously copying fields into an array:
<?php
use Assegaiphp\BlogApi\Posts\DTOs\UpdatePostDTO;
use RuntimeException;
public function updateById(int $id, UpdatePostDTO $dto): object
{
$updateResult = $this->postsRepository->update(
['id' => $id],
$dto,
);
if ($updateResult->isError()) {
throw new RuntimeException('Failed to update post.', previous: $updateResult->getErrors()[0]);
}
return $this->postsRepository->findOne([
'where' => ['id' => $id],
])->getFirst();
}
Use save() when you are working with an entity object and want insert-versus-update behavior to be decided by the entity state.
Delete records
The recommended default is a soft delete, because entities already ship with ChangeRecorderTrait and the ORM supports that flow out of the box.
<?php
use Assegai\Orm\Management\Options\RemoveOptions;
use RuntimeException;
public function deleteById(int $id): object
{
$post = $this->postsRepository->findOne([
'where' => ['id' => $id],
])->getFirst();
$removeResult = $this->postsRepository->softRemove(
$post,
new RemoveOptions(),
);
if ($removeResult->isError()) {
throw new RuntimeException('Failed to delete post.', previous: $removeResult->getErrors()[0]);
}
return $post;
}
Use a hard delete only when you are sure the row should be physically removed. The repository also exposes remove(), delete(), and restore() when your workflow needs them.
Understand the result objects
The ORM returns specialized result types instead of raw arrays. Those result objects are most useful inside repositories and services, where you need access to things like errors, totals, and raw database metadata.
At the application boundary, most services read more naturally if they unwrap those results and return plain objects or arrays.
The main result types are:
FindResultInsertResultUpdateResultDeleteResult
The most useful methods are:
isOk()andisError()getErrors()getData()getRaw()getTotalAffectedRows()
FindResult also gives you:
getFirst()for the first item in a list resultgetTotal()for the total record countisEmpty()for an easy emptiness check
Unwrapping ORM results before controllers
Controllers can stay very thin because the service can unwrap repository results before they reach HTTP:
<?php
namespace Assegaiphp\BlogApi\Posts;
use Assegai\Core\Attributes\Controller;
use Assegai\Core\Attributes\Http\Body;
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\Http\Post;
use Assegai\Core\Attributes\Param;
use Assegaiphp\BlogApi\Posts\DTOs\CreatePostDTO;
#[Controller('posts')]
readonly class PostsController
{
public function __construct(private PostsService $postsService)
{
}
#[Get]
public function findAll(): array
{
return $this->postsService->findAll();
}
#[Get(':id<int>')]
public function findById(#[Param('id')] int $id): object
{
return $this->postsService->findById($id);
}
#[Post]
public function create(#[Body] CreatePostDTO $dto): object
{
return $this->postsService->create($dto);
}
}
That lets the transport layer remain simple while the repository keeps the persistence logic.
Techniques that scale well
- Keep DTOs, entities, and services as separate responsibilities even when the feature feels small.
- Prefer module-level
data_sourceconfig over repeating the database name on every entity, and prefer the fully qualifieddriver:nameformat there. - Use
findOne()with an explicitwhereeven for primary-key lookups. It keeps the service intent obvious. - Reach for explicit relation loading instead of assuming a property is already hydrated.
Next step
If your model has real relationships, continue with ORM Relations.