So, you've got a class called Customer.
It has 47 methods. Some of them load it from the database. Some of them validate an email. Some of them calculate lifetime value. Some of them serialise it for an API response. One of them, and you're not entirely sure why, sends a welcome email.
The class started as "just a model". A year later, it's the thing every other file in the codebase imports. You add a feature, you touch Customer. You fix a bug, you touch Customer. The Slack channel for new joiners has a pinned message that reads "don't touch Customer without asking."
You don't have a bug. You have a missing distinction.
The distinction is between three kinds of object that PHP lets you spell the same way, they're all classes, they all have properties, they all have methods, but that play three completely different roles in the codebase. DTOs move data. Value Objects describe things. Entities are things. When you collapse them into one class, you get Customer with 47 methods. When you separate them, you get three small classes that each do one job well.
This is what each one is, what belongs inside it, and how to tell, on a Tuesday afternoon, in a real codebase, with a real deadline, which one you're actually building.
DTOs: Envelopes For Data In Flight
A Data Transfer Object is a typed envelope. Its only job is to carry a set of fields from one boundary to another without losing their names or types along the way.
The boundary is usually one of three places: an HTTP request coming in, an HTTP response going out, or a message crossing a process line (queue, RPC call, event bus). On one side of the boundary you have unstructured data, a JSON blob, a query string, a serialised payload. On the other side you have your domain. The DTO is the layer in between that turns the blob into something the IDE can autocomplete.
<?php
declare(strict_types=1);
namespace App\Application\Dto;
final class CreateOrderRequest
{
public function __construct(
public readonly string $customerId,
public readonly string $currency,
public readonly array $items,
public readonly ?string $couponCode = null,
) {}
}
Three things to notice.
The class is final and the properties are readonly. A DTO is a snapshot, once you've assembled it, nothing should mutate it. PHP 8.1's readonly modifier enforces that at the language level, which means you don't need a comment that says "please don't change these fields."
The constructor uses promoted properties. There's no separate field declaration block, no $this->customerId = $customerId ceremony. A DTO is supposed to be boring; the constructor should be a list of fields, nothing more.
There's no behavior. No validation. No getTotal(). No isFreeShipping(). The moment you put a method on a DTO that does anything beyond returning a field, you've started growing it into something else.
What belongs in a DTO
Public, immutable, named fields. Nullable defaults for optional inputs. A factory method that builds the DTO from a raw array (request payload, queue message body) and validates shape, not business rules.
public static function fromArray(array $data): self
{
return new self(
customerId: (string) $data['customer_id'],
currency: (string) $data['currency'],
items: (array) ($data['items'] ?? []),
couponCode: isset($data['coupon_code']) ? (string) $data['coupon_code'] : null,
);
}
That's it. fromArray is shape-coercion: are the keys there, do they cast to the right primitive types, are the optional ones handled? If the payload is missing customer_id entirely, you can throw here, that's still a structural problem, not a domain one. The DTO doesn't know whether customer_id exists in the database. That's somebody else's job.
What doesn't belong in a DTO
Business rules. Persistence. Equality. Anything stateful.
If you find yourself writing CreateOrderRequest::validate() and the method checks whether the coupon is expired, you've crossed the line. The coupon's expiration is a domain concern, and the moment the DTO knows about it, every test for that rule has to instantiate a DTO, and you've coupled an HTTP shape to a business rule for no good reason. The DTO is the envelope; the letter inside is somebody else's problem.
If you find yourself writing CreateOrderRequest::save(), stop. A DTO is not a database row. It might become one, through a repository, but it doesn't know that. The day you swap a REST API for a gRPC service, the DTO changes; the persistence layer doesn't.
Value Objects: Things Defined By Their Content
A value object describes something whose identity is its content. Two instances with the same fields are the same thing, full stop.
The classic example is money. Money(100, 'USD') is identical to any other Money(100, 'USD') you can construct, because there's nothing else about money that matters, there's no "this hundred dollars" separate from "that hundred dollars". If you change the amount, you don't have a modified hundred dollars; you have a different value. So value objects are immutable, and they're equal by content.
<?php
declare(strict_types=1);
namespace App\Domain;
final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency,
) {
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Money amount cannot be negative.');
}
if (strlen($currency) !== 3) {
throw new \InvalidArgumentException('Currency must be a 3-letter code.');
}
}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new \DomainException("Cannot add {$other->currency} to {$this->currency}.");
}
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amountInCents === $other->amountInCents
&& $this->currency === $other->currency;
}
public function __toString(): string
{
return sprintf('%.2f %s', $this->amountInCents / 100, $this->currency);
}
}
A few things this gives you that an int doesn't.
The constructor validates. You cannot construct an invalid Money. Every place in the codebase that has a Money knows it's been through the gate. That's the difference between "the type system protects you" and "the documentation protects you, sort of."
Operations return new instances. $balance->add($payment) doesn't mutate $balance; it gives you a new Money. The original is untouched, which is exactly how you'd want a 100 USD literal to behave. You can pass it around without worrying that some other code is going to silently change it.
The currency mismatch throws. You cannot accidentally add euros to dollars. That's a domain rule baked into the type, not a comment, not a TODO, not something you remember to write in code review. The class makes the wrong thing impossible to express.
Value object candidates hiding in your codebase
Most domains have more value objects than people realise. The exercise of finding them usually doubles the size of the domain layer and halves the size of the controller layer.
Likely candidates: EmailAddress, PhoneNumber, PostalAddress, DateRange, Percentage, Url, HexColor, Coordinates, IpAddress, Money, Quantity, Weight, Duration. Anything you currently model as a string or int and then validate in three different places when it crosses a boundary.
<?php
declare(strict_types=1);
namespace App\Domain;
final class EmailAddress
{
public function __construct(public readonly string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("'{$value}' is not a valid email.");
}
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(EmailAddress $other): bool
{
return strcasecmp($this->value, $other->value) === 0;
}
}
Now every method signature that takes an email takes EmailAddress, not string. The validation lives in one place. Method comparisons are case-insensitive because that's how email works. And ->domain() gives you a free helper that you'd otherwise have written four times across the codebase.
What doesn't belong in a value object
Anything stateful. Anything that talks to a database. Anything that depends on the wall clock, a value object should be reconstructible from its constructor arguments alone, and time() is not an argument.
A common slip: putting save() on a value object so you can persist it. Don't. A value object doesn't have an identity, so what would "save" even mean? You'd be saving a copy. If you need to store a value object, you store it as columns on an entity (or as a JSON blob), and let the entity own the persistence concern.
Entities: Things With Identity That Survives Change
An entity has an identity that outlives any of its fields.
A user is an entity. They can change their email, their name, their address, their phone number, every property the system knows about them, and they're still the same user. The identity is the row, not the data. user_42 yesterday and user_42 today are equal because the ID matches, not because the fields do.
The same goes for an order, a subscription, a project, a ticket, a shipment. Anything you'd refer to as "the one we were talking about earlier" is an entity. The phrase only makes sense if there's a stable identity.
<?php
declare(strict_types=1);
namespace App\Domain;
final class Order
{
private OrderStatus $status;
private Money $total;
public function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
Money $initialTotal,
) {
$this->status = OrderStatus::Draft;
$this->total = $initialTotal;
}
public function applyCoupon(Coupon $coupon): void
{
if ($this->status !== OrderStatus::Draft) {
throw new \DomainException('Coupons can only apply to draft orders.');
}
$this->total = $coupon->applyTo($this->total);
}
public function place(): void
{
if ($this->status !== OrderStatus::Draft) {
throw new \DomainException('Order has already been placed.');
}
if ($this->total->amountInCents === 0) {
throw new \DomainException('Cannot place an order with zero total.');
}
$this->status = OrderStatus::Placed;
}
public function total(): Money
{
return $this->total;
}
public function status(): OrderStatus
{
return $this->status;
}
public function equals(Order $other): bool
{
return $this->id->equals($other->id);
}
}
Notice the shape.
The ID is readonly, entities don't get to forget who they are. The mutable fields (status, total) are private, so no outside code can reach in and reassign them. The only way the order transitions from Draft to Placed is through place(), which enforces the rules. That's the whole point: the entity owns its rules, and outside code has to ask it to do things, not change it directly.
The total is a Money, a value object, and applyCoupon replaces it with a new Money rather than mutating the old one. That's the value-object pattern showing up inside an entity. Entities frequently hold value objects, and the rule of thumb is that the entity's fields are mutable but the values themselves aren't.
equals compares IDs, not fields. Two orders with the same items are not "the same order"; they're two orders that happen to look alike. The day your equality check is field-by-field on an entity, you've quietly announced that two distinct rows are the same, and the cascading bugs will not be fun.
What belongs in an entity
Identity. Mutable state that needs to be guarded. Business rules that mutate that state. Methods that read the state for downstream computation (->total(), ->isFulfillable()). References to other entities by ID, or value objects directly.
The methods on an entity should read like sentences in your domain's vocabulary: $order->applyCoupon($coupon), $order->place(), $user->suspend('payment-failure'), $subscription->renew(). If the names sound like a manager describing the business rather than a programmer describing the code, you're on the right track.
What doesn't belong in an entity
Persistence. Serialization. Anything I/O.
A common pattern in older PHP code is the Active Record style: $user->save() writes itself to the database. It feels convenient, the entity knows about its row, the row knows about the entity, what's not to like?, but it conflates two concerns. The domain rule "a suspended user can't change email" and the persistence rule "updates use optimistic locking on the version column" both end up in the same class. You can't unit-test the first without standing up the second. The class grows in two directions at once.
The cleaner separation is a repository: the entity is a pure domain object; the repository is the gateway to the database. The entity does not know its repository exists. That's how you get to unit tests that don't touch a database, and integration tests that don't need to know what business rules are being exercised.
The Decision: What Belongs Where
Here's the decision tree I actually use when I'm staring at a piece of data and trying to figure out which kind of object it should be.

Three questions, in this order.
1. Did this come from outside the system, or is it going there? If yes, and you mostly need it for the trip, it's a DTO. The CSV row that just got uploaded, the JSON payload of an inbound webhook, the array your queue worker pops off Redis, those are DTOs until proven otherwise. Their job is to be a typed envelope for that trip.
2. Is this thing defined by its content? If two instances with the same fields would be indistinguishable, it's a value object. Money, dates, ranges, coordinates, codes, identifiers (sometimes, see below), measurements, descriptions. Validation happens in the constructor; once it exists, it's known-good; you cannot mutate it; you compare by content.
3. Does this thing have an identity that survives changes to its data? If yes, it's an entity. Users, orders, subscriptions, projects, invoices, tickets. State is mutable but private; rules live as methods that change the state lawfully; equality is by ID.
That's the order, and that's all the questions. If the same piece of data could plausibly be more than one of these, that's a clue you're looking at multiple objects glued together, and the cleanest move is to split them.
Worked example: a customer record
You've got a record that lives in the database with a customer_id, an email, a name, a phone, a status, and a loyalty_tier. The HTTP API exposes it. A queue worker updates it nightly. Code in three services reaches into it.
Where do the pieces go?
The thing in the database is an entity: Customer. It has an identity (CustomerId), mutable state (status, loyaltyTier), and rules (suspend(), promoteToVip(), recordPurchase()). It does not have a save() method; the CustomerRepository handles that.
The email, the phone, the CustomerId itself, the LoyaltyTier (which is probably an enum, but treat enums as a degenerate case of a value object), the Money for the lifetime-value calculation, those are value objects. They get validated in their own constructors and the Customer entity holds them.
The shape the HTTP API accepts to create a customer is a DTO: CreateCustomerRequest. The shape the API returns when you fetch one is also a DTO: CustomerResponse. The queue worker's nightly-update payload is a DTO. Three different DTOs for one entity, and that's fine; they're three different boundaries with three different shapes, and forcing them all to be the same shape is what created Customer with 47 methods in the first place.
The mapping between DTO and entity is a small, boring piece of code that lives in the application layer:
public function handle(CreateCustomerRequest $request): CustomerResponse
{
$customer = new Customer(
id: CustomerId::generate(),
email: new EmailAddress($request->email),
name: $request->name,
);
$this->customers->save($customer);
return CustomerResponse::fromEntity($customer);
}
The DTO comes in. The entity is constructed (with value objects inside it). The repository persists it. A different DTO goes out. Each class does its one job. None of them know more than they need to.
Mistakes That Quietly Merge The Three
The mistakes don't show up on day one. They show up on day 400, when the codebase has aged, three of the original engineers have left, and somebody runs find . -name "*.php" | xargs wc -l | sort and discovers Customer.php is 2,400 lines long. Here are the ways it usually happens.
The DTO that grew rules
You started with CreateOrderRequest as a plain envelope. Then you added a validate() method that checks the coupon exists. Then isFreeShipping() so the controller doesn't have to compute it. Then applyDiscount() because "it has all the data anyway." A year later, the DTO is doing arithmetic with money it shouldn't know about, and you can't test the discount logic without instantiating a request.
Spot it: business methods on a class whose name ends in Request, Response, Dto, or Command. Fix: move the logic into the domain, usually a value object or an entity method, and let the DTO go back to being an envelope.
The value object that's actually an entity
You modeled Address as a value object. Equal by content, immutable, validated in the constructor. Six months later, the product team adds "users should be able to label addresses as Home/Work/Other and pick a default." Now the address has state that isn't part of its content, it has a role in the user's life. You start mutating it. You add a setLabel(). Suddenly your value object isn't immutable, the equality is broken (two Home addresses at the same street?), and the abstraction is sliding sideways.
The honest fix is to acknowledge you now have an entity (AddressBookEntry, with an ID) that contains a value object (PostalAddress, the actual address fields). The entity has the mutable state (label, default flag); the value object has the address content.
The entity that's actually a value object
The opposite slip. You modeled Money as an entity with a generated MoneyId, because "everything in the database has an ID." Now every reference to money carries a row pointer. Two payments of $100 are different Money entities. You can't compare them with ==. You can't construct one in a unit test without inventing an ID.
You haven't modeled money; you've modeled a ledger entry about money, which is a different thing. The fix is to model Money as a value object and have the ledger entity (Payment, Transaction) hold a Money field. Identity belongs to the transaction, not the amount.
The entity that's three entities
User started small. Then it grew billing fields. Then it grew preferences. Then it grew session state. Now it has 47 methods because it's secretly playing three roles: the account (auth, identity, status), the customer (billing, subscriptions), and the profile (display preferences, settings). They share an ID, so it felt natural to share a class. They don't share rules, so the class is a mess.
The fix is to split along the rule boundaries. Account, Customer, UserProfile, three entities that happen to share a UserId. They don't need to be in the same class just because they're keyed the same way. Three small entities with focused rules are easier to reason about than one big entity with all of them.
A Final Test You Can Apply In Code Review
Pick any class in your Domain/ folder and ask one question: "What would change about this class if we swapped the database, swapped the HTTP framework, or swapped the queue system?"
A pure entity: nothing changes. Its rules are the same whether the data sits in Postgres or MySQL.
A pure value object: nothing changes either. The rules of money or time or addresses don't care how they're stored.
A DTO: it changes when the boundary changes. If you swap REST for gRPC, the DTO's shape shifts; that's expected, because it's literally a boundary artifact.
If a class in Domain/ changes when you swap the database, it's not a domain object, it's an ORM model wearing a domain object's clothes. If a class in Application/Dto/ survives a database swap and defines its own equality and enforces business rules, it's not a DTO, it's a value object or entity that got filed in the wrong folder.
Pick three classes from your repo this afternoon. Ask the question. The honest answer will tell you where they actually belong.



