PHP / Intermediate: objects, persistence, and tests / Lesson 7
Objects, interfaces, and domain invariants
Use classes to protect valid state and interfaces to express substitutable behavior. Separate object identity, readonly properties, and deep immutability through small pricing models.
On this page
Lesson goals
- Validate construction and state transitions.
- Explain interfaces and dependency injection.
- Distinguish readonly constraints from deep immutability.
A class should preserve a meaningful rule
A class is useful when its operations keep related state valid, not merely because fields can be grouped behind a name. A price object can reject a negative amount at construction and expose an integer-cent value for calculations. Avoid public mutable fields when arbitrary assignment would bypass that invariant. Typed properties constrain type but may remain uninitialized unless construction assigns them, so reading them too early can fail. Keep constructors small and deterministic rather than opening real connections or reading global request state. An object that starts valid and preserves its transitions is easier to reason about than scattered setter checks.
Readonly is a constraint with a defined scope
Readonly properties, available since PHP 8.1, restrict reassignment under their language rules; readonly classes arrived in PHP 8.2. They do not recursively freeze objects stored in properties. The example uses only a scalar integer and a PHP 8.3 baseline, so the value object remains simple. A readonly class still needs constructor validation, and it is not an automatic serializer or security boundary. Prefer small immutable-like values for domain facts and explicit operations for mutable workflows. Document version-dependent behavior rather than assuming every newer readonly rule is present in the minimum runtime.
<?php
declare(strict_types=1);
final readonly class Price {
public function __construct(public int $cents) {
if ($cents < 0) { throw new InvalidArgumentException("negative price"); }
}
}
$price = new Price(125);
echo $price->cents, PHP_EOL;
try { new Price(-1); } catch (InvalidArgumentException $error) { echo $error->getMessage(), PHP_EOL; }
125
negative price
Readonly prevents arbitrary rebinding, while the constructor enforces nonnegativity. The example relies on PHP 8.2-or-later class syntax.
An interface defines observable behavior
An interface specifies methods that callers can rely on, while an implementation chooses how to produce the result. Pass the dependency explicitly so a calculation does not reach into a global service container. Substitution requires preserving result and failure meaning, not just matching method names. A test pricing policy can be injected without installing a framework or connecting a service. Prefer composition for independent responsibilities such as pricing, formatting, and persistence. Deep inheritance often makes unrelated assumptions travel through a base class, which complicates both tests and changes.
Inject a bounded policy and test its result
The example supplies a policy object to a quote function. Integer arithmetic computes an illustrative ten-percent reduction for a value divisible by ten, avoiding a hidden rounding claim. A production percentage policy would validate ranges and specify overflow and rounding for every supported amount. The caller depends on the interface rather than the concrete class, so another policy can be tested through the same operation. Do not confuse this local substitution evidence with proof that a payment gateway or storage layer follows its real contract; those adapters require their own controlled integration checks.
<?php
declare(strict_types=1);
interface Pricing { public function apply(int $cents): int; }
final class TenPercent implements Pricing {
public function apply(int $cents): int { return intdiv($cents * 90, 100); }
}
function quote(Pricing $policy, int $cents): int { return $policy->apply($cents); }
echo quote(new TenPercent(), 1000), PHP_EOL;
900
The exampleโs 1000-cent input yields 900. General rounding and amount limits remain separate domain decisions.
Object handles and cloning need ownership rules
Two variables can refer to the same object, and strict identity asks whether they reach that same instance. clone creates a shallow object copy unless custom cloning logic changes the behavior; nested objects may still be shared. Avoid cloning resources or active service objects as a substitute for clear lifecycle design. For a domain update, returning a new value object can be simpler than copying an arbitrary graph. Keep serialization separate from construction so untrusted payloads cannot assign sensitive state automatically. Test invariants after construction, permitted changes, rejected changes, and any deliberate copy operation.
Common errors and fixes
A typed property is read before initialization
- Why it happens
- The constructor did not establish every required field.
- How to fix it
- Initialize required state or model optional state explicitly.
- Prevent it next time
- Test construction paths before public operations.
Readonly is assumed to freeze nested objects
- Why it happens
- Property reassignment and deep object mutation are conflated.
- How to fix it
- Use an intentional immutable-like graph or controlled nested operations.
- Prevent it next time
- Review returned references and nested ownership.
A substitute changes error meaning
- Why it happens
- Interface compatibility is judged only by syntax.
- How to fix it
- Preserve the documented success and failure contract.
- Prevent it next time
- Test implementations through shared behavior examples.
Check your understanding
1. Why validate a readonly price if its property cannot be reassigned?
Show worked solution
Readonly restricts later assignments but does not make an initially negative price valid. Constructor validation establishes the domain invariant first. Test zero, a normal amount, and a negative amount; also define safe amount limits before multiplication in a quote calculation.
2. How can a quote rule be tested without a framework container?
Show worked solution
Create a policy implementing the interface and pass it directly to the quote function. Compare known expected cents and failure behavior. This makes the dependency visible and avoids application bootstrap, real services, and unrelated configuration in a focused language test.
Key takeaways
- A useful class protects a domain rule through every public operation.
- Interfaces describe behavior; readonly and cloning have narrower guarantees.