7 min readCalm pace · scan the outline anytime

Ports and persistence

Repository ports, manual Entity mapping, and keeping ORM out of Domain and Application.

Ports and persistence

Purpose: persist and load Domain data without teaching Use Cases about an ORM.

Component map (one feature)

Typical folders

Application/DTO/{Capability}/CreateXDTO.php
Application/UseCases/{Capability}/CreateXUseCase.php
Domain/Entities/XEntity.php
Domain/Ports/{Capability}/XRepositoryInterface.php
Infrastructure/Persistence/.../XRecord.php          # host-specific
Infrastructure/Persistence/.../XRepository.php      # implements port

Core requires: manual mapping and no ORM types in Domain/Application.

Domain/Ports/{Capability}/XRepositoryInterface.php
Infrastructure/Persistence/.../XRepository.php   # implements port
Infrastructure/Persistence/.../XRecord.php       # host-specific row type

Map Entity ↔ record inside the adapter. Application never sees the record type. Persistence access is repository-only; Use Cases depend on the port and receive Domain Entities.

Illustrative host mappings — not Core MUST

Persistence quarantine (Infrastructure only)

HostTypical place
LaravelEloquent models / repositories under `…/Eloquent/`
SymfonyDoctrine entities / repositories under `…/Doctrine/`
YiiCycle (Yii3) or ActiveRecord (Yii2 brownfield) in Infra
CodeIgniterCI Models / Query Builder only in Infra repositories
CakePHPTable / Entity ORM types quarantined in Infra
SpiralCycle ORM via cycle-bridge in Infra
SlimBring-your-own ORM/SQL — still Infra-only
MezzioBring-your-own (laminas-db, Doctrine, …) — Infra-only

Full topic pages: Adapters overview

Port sketch

interface OrderRepositoryInterface
{
    public function save(OrderEntity $order): OrderEntity;

    public function findByCode(string $code): ?OrderEntity;
}
public function __construct(
    private OrderRepositoryInterface $orders,
) {}

The composition root binds the port to a concrete adapter.

Mapping rules

DirectionWhereInput → Output
WriteRepository adapterXEntity → record attributes → DB
ReadRepository adapterDB → record → XEntity
HTTP outUIEntity fields → JSON / resource

Never return an ORM model (or query builder) from the repository to the Use Case.

Sequence — create then return

Nested helpers still use ports

When PlaceOrderUseCase delegates to PrepareOrderLines:

  • Helpers use the same Domain ports.
  • Helpers must not become UI entry points.
  • Peer calls (Warehouse, Directory) are ACL ports, not this module’s repositories — see cross-module ACL.

Anti-patterns

Anti-patternFix
Entity extends ORM modelEntity = plain PHP; record only in Infrastructure
Repository returns paginator of ORM modelsMap items to Entities (or a Domain page DTO)
Use Case builds SQL stringsBelong in the repository adapter
Sharing ORM relations across modulesUse ACL / Events; no cross-module business joins via foreign models

Next: cross-module ACL.

Modular Hexagonal Domain-Driven Design
Core 1.0.0-draft