Course outline

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.

About 4 min readContent reviewed
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 A validated scalar value object
<?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; }
Expected output
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 Explicit composition through an interface
<?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;
Expected output
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.

Official references