Skip to content

PHP & Domain Modeling / Data DTOs and domain behavior

From Database Rows to Business Rules: Domain Models in Semitexa

By ·

A row can store when an API credential was revoked. A business object can express what revocation means, return a changed credential, and answer whether it remains active. Semitexa connects those responsibilities through a persistence resource, an explicit mapper, and a domain-facing repository. Follow that boundary through real code and a verified read, change and write cycle.

Begin with “revoke this credential”

The API package has a MachineCredential domain model for machine-to-machine credentials. It carries identity, scopes, usage information, rotation time, and revocation time. Application code can obtain it through MachineCredentialRepositoryInterface and perform a named operation:

// $credentials implements MachineCredentialRepositoryInterface.
$credential = $credentials->findById($credentialId);
if ($credential === null) {
    throw new DomainException('Credential not found.');
}

$revoked = $credential->revoke($at);
$credentials->update($revoked);

This application-level example uses the existing model and repository contract. revoke() returns a new credential; the caller passes that returned value to update(). The operation communicates intent in the vocabulary of the API domain, while the repository implementation handles its database representation.

The model’s isRevoked() and isActive() methods derive their answers from the revocation timestamp. The authentication handler checks that state and refuses a revoked credential. The domain object supplies the meaning of the state, and the handler applies it to an authentication decision.

This is a concrete way to inspect domain modeling. Martin Fowler’s Domain Model pattern places data and behavior together in objects representing domain concepts. The useful question for this walkthrough is where that behavior lives and how storage reaches it.

The Data DTO describes the persistence representation

On the storage side, MachineCredentialResourceModel maps the api_machine_credentials table. It is a readonly class with public constructor properties and ORM attributes. In this article, “Data DTO” refers to that persistence-facing resource: a typed representation passed between the ORM and the mapper.

Here are selected parameters from its constructor; the complete class also declares client name, secret hash, tenant association, creation time, usage time, and rotation time:

// Selected constructor parameters from MachineCredentialResourceModel.
#[PrimaryKey(strategy: 'uuid')]
#[Column(type: MySqlType::Binary, length: 16)]
public string $id,

#[Column(name: 'scopes_json', type: MySqlType::Json)]
public array $scopes,

#[Column(name: 'request_count', type: MySqlType::Int)]
public int $requestCount,

#[Column(name: 'revoked_at', type: MySqlType::Datetime, nullable: true)]
public ?DateTimeImmutable $revokedAt,

The declarations answer storage questions: which property maps to which column, which SQL type applies, and which values may be null. The binary UUID and JSON scopes are database representation choices. The resource makes those choices visible in one place.

The ORM hydrator performs column-type conversion before the mapper receives the resource. In our verified example, a 16-byte stored identifier became a canonical UUID string, JSON became an array for the declared array property, and datetime values became DateTimeImmutable instances.

Representations observed in the isolated round trip
ValueDatabase representationTyped PHP representation
Credential ID16 bytes36-character UUID string
ScopesJSON textArray of scope strings
Usage and revocation timesStored datetime valuesDateTimeImmutable objects
Request countInteger columnInteger; domain construction keeps it nonnegative

The mapper declares which two models it connects

MachineCredentialMapper implements ResourceModelMapperInterface and declares its resource/domain pair:

#[AsMapper(
    resourceModel: MachineCredentialResourceModel::class,
    domainModel: MachineCredential::class,
)]

Its toDomain() method constructs the business object from the hydrated persistence values. This is the actual method from the API package:

public function toDomain(object $resourceModel): object
    {
        $resourceModel instanceof MachineCredentialResourceModel || throw new \InvalidArgumentException('Unexpected resource model.');

        return new MachineCredential(
            id: $resourceModel->id,
            clientName: $resourceModel->clientName,
            secretHash: $resourceModel->secretHash,
            scopes: $resourceModel->scopes,
            tenantId: $resourceModel->tenantId,
            createdAt: $resourceModel->createdAt,
            lastUsedAt: $resourceModel->lastUsedAt,
            requestCount: $resourceModel->requestCount,
            rotatedAt: $resourceModel->rotatedAt,
            revokedAt: $resourceModel->revokedAt,
        );
    }

The reverse method, toSourceModel(), reads the domain model’s getters and constructs a MachineCredentialResourceModel. Both directions are explicit and independently inspectable.

The conversion here is intentionally straightforward. The types already agree, so most values can pass through unchanged. The separation still matters: the public storage record becomes an encapsulated object with behavior, and a changed business object later becomes a persistence record again.

MapperRegistry keys its definitions by the pair of resource class and domain class. It can represent more than one domain view of a resource through different pairs. A duplicate declaration for the same pair is an error; a missing pair also produces an explicit failure. Callers therefore identify the domain model they want.

Business behavior has observable consequences

MachineCredential keeps its state private and exposes named operations. recordUsage($at) returns a new instance with an incremented request count and a usage timestamp. revoke($at) returns a new instance with a revocation timestamp. rotateSecretHash() returns a new instance carrying the replacement hash and rotation time.

The previous object remains unchanged. That matters at the call site: a caller must retain and persist the new instance. Calling revoke() and discarding its return value leaves the caller holding its earlier active credential.

The constructor normalizes a negative request count to zero. That is the implemented invariant: the business object exposes a nonnegative counter. It does not throw for a negative input. Directly assigning the private count is also blocked by PHP’s access rules.

Scope behavior is similarly explicit. hasScope() uses strict membership in the credential’s scope list. The example credential permits articles:read and returns false for articles:write. A permission check still needs a caller that asks the appropriate question and acts on the answer.

These methods make the model useful to application code. They place domain decisions behind an object interface that can be exercised without loading ORM metadata or opening a database connection.

The repository contract returns a business object

MachineCredentialRepositoryInterface::findById() returns ?MachineCredential. Its save and update operations accept that same domain type. The contract belongs to the API domain; its ORM-backed implementation declares #[SatisfiesRepositoryContract].

The implementation obtains a DomainRepository configured with both MachineCredentialResourceModel::class and MachineCredential::class. On a read, the query hydrates the resource and the mapper registry converts it to the requested business model.

Read:
SQL row → ResourceModelHydrator → MachineCredentialResourceModel
        → MachineCredentialMapper → MachineCredential

Write:
MachineCredential → MachineCredentialMapper
                  → MachineCredentialResourceModel
                  → dehydration and persistence → SQL

The concrete repository can use typed column references and query ordering internally. For example, its client-name lookup selects an active credential and orders by creation time. The contract’s consumer receives the selected business object without needing those column declarations.

This boundary gives a test seam. An application service can depend on the repository contract, while mapper and repository tests examine storage translation separately. The actual implementation remains responsible for performing the queries promised by its contract.

Follow a changed model back into the database

The generic repository’s update path delegates to AggregateWriteEngine. The engine asks the registry to map the domain object to the configured resource class, then persists that resource. Dehydration converts mapped PHP values into their column representations.

Our isolated round trip exercised that path with the real API model, resource, and mapper. It inserted a credential, loaded it as MachineCredential, applied usage and revocation operations, saved the returned values, and loaded the record again.

The final model retained one recorded use and both timestamps. It reported revoked and inactive. The earlier loaded instance remained active with its original count. In the database, the identifier occupied 16 bytes and the scopes remained JSON. The mapper-to-resource direction returned an array of scopes and a datetime object, letting the ORM perform the storage conversion.

That division prevents duplicate conversions. ResourceModelMapperInterface documents that column-type conversion belongs to the ORM. A mapper should not decode a binary UUID again after hydration has already produced its canonical string. It handles domain/storage shape differences that remain beyond those column conversions.

The abstraction gives rules a place to live

A mapper connects objects; the domain model and application must still implement their business policy. The reviewed credential model has specific behavior, but it does not enforce every possible credential lifecycle restriction. For example, secret rotation preserves the existing revocation timestamp, and usage recording itself does not reject a revoked object.

The authentication handler supplies the surrounding workflow: it finds the credential, rejects revoked credentials, verifies the presented secret, then records and persists usage after successful authentication. Those checks belong to that handler. The mapper does not perform authentication as a side effect of loading a row.

Persistence concerns also keep their own scope. This particular resource has an optional tenant association; that field alone is not an automatic tenant-isolation policy. Concurrency and transaction guarantees depend on the repository’s write path and configuration. The single-process round trip demonstrated here does not establish concurrent counter-update behavior.

Semitexa’s PHPStan tooling includes a rule that detects a mapper declaration using the same class for its resource and domain model. When configured, that check protects the declared boundary. Useful domain modeling still requires meaningful operations, invariants, and tests for the language of the application.

The shared-table mechanism from the Schema Sync article fits alongside this design. Several module resources can contribute to one physical table. Each resource can then participate in its own explicit domain mapping. Storage composition and business behavior have separate representations and can evolve through their respective boundaries.

A boundary you can inspect and exercise

The practical sequence is visible: define the persistence resource, declare the mapper pair, return domain objects through the repository contract, implement behavior on the domain model, and persist the result of that behavior. Each step has a class and a testable responsibility.

We checked the installed API and ORM implementations on September 30, 2026. The reproducible evidence used a manually defined in-memory SQLite table with the credential’s column names. It exercised the actual API resource, mapper, and business class through generic DomainRepository insert, find, and update operations. It did not use the production database or submit a real authentication request.

Twenty-two existing tests passed with 52 assertions, covering credential behavior, the authentication handler, mapper registration, and domain repository operations. The isolated round trip also checked storage formats, immutability, nonnegative count, scope answers, and the state reloaded after writes.

Source and reproducible example
  • semitexa/api: MachineCredential.php and MachineCredentialRepositoryInterface.php.
  • semitexa/api: MachineCredentialResourceModel.php, MachineCredentialMapper.php, and MachineCredentialRepository.php.
  • semitexa/api: MachineAuthHandler.php for the authentication decision.
  • semitexa/orm: MapperRegistry.php, ResourceModelMapperInterface.php, DomainRepository.php, and AggregateWriteEngine.php.
  • semitexa/core: NoOpMapperRule.php for the static-analysis check.

The local evidence script is var/docs/domain-model-boundary-example.php. Its database is in memory, and its credential values are synthetic.

For the wider architectural view, continue with Domain-Driven Design in Semitexa: follow a fulfilment decision through business contexts, contracts, guards, and consistency boundaries.

For the storage side of the story, continue with From PHP Attributes to SQL. It traces how resource declarations become a merged schema and an inspectable DDL plan. Here, we followed those typed records onward into objects that carry business meaning.

Explore the technical stack

Trace the boundary in your own domain.

Start with the domain-model documentation, then inspect the resource, mapper, repository contract, and operation behind one business rule.

Have a product idea?One free MVP every month