Objects Namespaces And Application Architecture
Constructors and Destructors
A constructor runs when an object is created. Its job is to put the object into a usable state immediately.
A destructor runs when PHP destroys an object. Destructors exist, but they are much less common in application code. Important business work should not depend on a destructor because timing can be hard to reason about.
Use constructors for required state
Without a constructor, code can create an object and forget to fill important properties. A constructor makes required values explicit.
<?php
declare(strict_types=1);
class Product
{
public function __construct(
public string $sku,
public string $name,
public int $pricePennies,
) {
}
}
$product = new Product('KB-101', 'Keyboard', 2499);
echo $product->sku . ' / ' . $product->name . PHP_EOL;
// Prints:
// KB-101 / Keyboard
This uses constructor property promotion, which is common in modern PHP. The constructor parameters become properties automatically.
Validate constructor input
A constructor can protect the object from invalid starting values.
<?php
declare(strict_types=1);
class OrderLine
{
public function __construct(
public string $sku,
public int $unitPricePennies,
public int $quantity,
) {
if (trim($sku) === '') {
throw new InvalidArgumentException('SKU is required.');
}
if ($unitPricePennies < 0) {
throw new InvalidArgumentException('Unit price must not be negative.');
}
if ($quantity < 1) {
throw new InvalidArgumentException('Quantity must be at least 1.');
}
}
public function lineTotalPennies(): int
{
return $this->unitPricePennies * $this->quantity;
}
}
$line = new OrderLine('KB-101', 2499, 2);
echo $line->lineTotalPennies() . PHP_EOL;
// Prints:
// 4998
After construction, the rest of the application can trust that an OrderLine has the basic data it needs.
Constructors should not do too much
Constructors should usually assign and validate state. Be careful when they send emails, write files, call APIs, or run database queries.
<?php
declare(strict_types=1);
class WelcomeEmail
{
public function __construct(
public string $recipient,
public string $subject,
) {
if (filter_var($recipient, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException('Recipient email is invalid.');
}
}
}
$email = new WelcomeEmail('nia@example.com', 'Welcome');
echo $email->subject . PHP_EOL;
// Prints:
// Welcome
Creating the message object is separate from sending it. That keeps object creation predictable and testable.
Use named constructors when creation has meaning
Sometimes one class has more than one useful way to create an object. A named constructor is a static method that explains that creation path.
<?php
declare(strict_types=1);
class Money
{
public function __construct(public int $pennies)
{
if ($pennies < 0) {
throw new InvalidArgumentException('Money cannot be negative here.');
}
}
public static function poundsAndPennies(int $pounds, int $pennies): self
{
return new self(($pounds * 100) + $pennies);
}
}
$money = Money::poundsAndPennies(24, 99);
echo $money->pennies . PHP_EOL;
// Prints:
// 2499
Named constructors can make calling code more expressive than a long list of primitive constructor arguments.
Destructors are for cleanup, not business rules
Destructors can release resources, but most PHP application code should close files, commit transactions, and send messages explicitly.
<?php
declare(strict_types=1);
class TemporaryNote
{
public function __construct(public string $path)
{
file_put_contents($this->path, "temporary\n");
}
public function __destruct()
{
if (is_file($this->path)) {
unlink($this->path);
}
}
}
$path = tempnam(sys_get_temp_dir(), 'note_');
$note = new TemporaryNote($path);
echo is_file($path) ? 'exists' : 'missing';
echo PHP_EOL;
unset($note);
echo is_file($path) ? 'exists' : 'missing';
echo PHP_EOL;
// Prints:
// exists
// missing
This is a small cleanup example. Do not hide important application behaviour in __destruct().
What to remember
Constructors make required object state explicit and should leave objects usable immediately. Validate invariants there, keep side effects modest, use named constructors when creation needs a clearer name, and reserve destructors for limited cleanup rather than business workflows.
Constructors Establish Valid State
A constructor should leave the object ready for every public operation. If an OrderLine requires a product identifier, positive quantity, and non-negative unit price, accepting an empty object and asking callers to set three properties later creates a period in which invalid state exists. Every method then has to defend against incomplete initialization.
Validate invariants at construction and throw a domain-appropriate exception before returning an instance. This is different from validating an entire workflow. A constructor can prove that a date range has a start before its end; it should not call a remote calendar service to decide whether the dates are available.
Property promotion and readonly properties can make required state concise, but they do not remove the need for validation. Put checks in the constructor body and normalize only when the normalization is unambiguous. Silently trimming or lowercasing an identifier may hide malformed input if the original spelling carries meaning.
Keep I/O Out Of Ordinary Construction
Database queries, HTTP calls, email delivery, and large filesystem reads make constructors difficult to reason about. Callers expect new Service(...) to establish local state. A constructor that can block, retry, or partially change external state creates objects whose existence is ambiguous after failure.
Inject ready collaborators through the constructor, then perform I/O in an explicit method. If creation genuinely requires external work, use an application service or named factory whose return type and exceptions describe the operation. The distinction makes retries and transaction boundaries visible.
Constructors also should not publish events or register the object in global collections. If later validation fails, external observers may have seen an object the caller never received. Complete the use case first, persist within the chosen transaction, and publish through an outbox or explicit post-commit step where reliability matters.
Named Constructors Express Different Inputs
Named constructors are useful when an object can be created from several meaningful representations. Money::fromMinorUnits() and Money::fromDecimalString() tell reviewers which parsing rules apply. Both methods can delegate to one private constructor after producing the canonical state.
Do not use named constructors merely to rename new. Each should explain a distinct source or invariant. Avoid a generic fromArray() in domain code unless the array shape is itself a stable contract; typed parameters or a dedicated input object expose missing fields and units more clearly.
Factories belong outside the value object when creation needs repositories, clocks, random identifiers, or provider configuration. This keeps the constructed object's validity separate from application orchestration and makes the collaborator graph testable.
Constructor Exceptions And Partial Resources
When validation fails before resources are acquired, throw immediately. If construction must acquire a native resource, release anything already acquired before propagating failure. Better still, separate acquisition into an explicit method or factory that can use try and finally around the complete sequence.
PHP will not call a destructor on an object whose constructor did not complete normally. Code must not rely on __destruct() to clean partial construction. Native extensions may release their own handles, but application-owned cleanup should be explicit and verified.
Constructor exception types are part of the API. A value object might throw InvalidArgumentException; a domain factory may return a result type or throw a domain exception; an infrastructure factory may translate a driver failure. Do not leak an arbitrary provider exception through a stable domain constructor.
Inheritance And Construction Order
A child constructor does not automatically call its parent constructor. When a parent establishes required state, the child must invoke parent::__construct() with valid arguments. Review constructor changes across every subclass because adding a required parent parameter can break distant children.
Avoid calling overridable methods from a constructor. The child portion of the object may not yet be initialized, so dynamic dispatch can execute child code against incomplete state. Establish the full object first and invoke extension behavior through normal methods afterward.
Deep constructor hierarchies are a design warning. If a child passes many values through several parent layers only to reuse a helper, composition may produce a smaller and more stable contract.
Destructors Are A Last-Resort Cleanup Hook
__destruct() runs when an object is destroyed, often near request shutdown, but exact timing depends on references, cycles, shutdown order, and process lifetime. Fatal errors and abrupt process termination can prevent intended work. A long-running worker may keep an object alive far longer than one HTTP request.
Use destructors only for best-effort release of local resources when the underlying API needs it. Never depend on a destructor to save business data, commit a transaction, send an audit event, or release a distributed lock. Those operations require observable success and explicit failure handling.
Prefer an explicit close(), commit(), or release() method called inside try/finally:
<?php
declare(strict_types=1);
$export = $exportFactory->open($path);
try {
$export->writeRows($rows);
} finally {
$export->close();
}
Make repeated cleanup safe when practical. Idempotent close() behavior simplifies exception paths and lets a destructor perform a final best-effort call without duplicating effects.
Hydration And Serialization
ORMs, serializers, reflection, and unserialize() may create or populate objects through paths that differ from ordinary construction. Do not assume a constructor alone protects an invariant if infrastructure bypasses it. Configure the mapper to call a valid factory or validate at the persistence boundary.
Serializing rich objects across queue or session boundaries couples stored data to class names, property visibility, and constructor evolution. Prefer explicit messages with versioned scalar fields. Reconstruct a domain object through its validated constructor when consuming the message.
Verification
Test the smallest and largest valid constructor inputs plus each invalid boundary. Assert that invalid construction returns no usable object and does not perform external side effects. For named constructors, run a shared set of invariant assertions against every supported representation.
Test explicit cleanup after success, after an exception in the middle of work, and after a repeated close() call. For long-running workers, perform several jobs in one process and inspect open handles or temporary files. A unit test that ends immediately after constructing an object will not reveal lifecycle leaks.
Review each constructor parameter for unit, nullability, ownership, and lifetime. A growing list of unrelated collaborators often indicates that the class has accumulated several responsibilities. Fix the responsibility boundary instead of hiding the list behind a container.
Defaults And Dependency Cycles
A constructor default should represent a stable domain default, not whatever configuration happens to be common today. A default currency, timezone, retry count, or region often varies by tenant or deployment and should be supplied explicitly by the composition root. Defaults are safer for value-level choices such as an empty immutable collection whose meaning cannot change with environment.
Circular constructor dependencies usually reveal an unclear responsibility boundary. If an order service needs an invoice service and the invoice service needs the order service, adding lazy container lookups only hides the cycle. Extract the shared policy, publish an event after the owning transaction, or create an orchestrator that depends on both. The resulting constructors should describe a directed object graph that can be built and validated at startup.
When many optional parameters accumulate, replace booleans and nullable scalars with a validated options object or separate use cases. Call sites should communicate why construction differs, not rely on remembering the meaning of the seventh positional argument.
After this lesson, you should be able to use constructors to establish valid local state, separate I/O-heavy creation into explicit factories, design meaningful named constructors, handle inheritance and partial failures, use deterministic cleanup, and test object creation across persistence and process lifecycles.
Practice
Task: Create a valid order line
Write an OrderLine class that cannot be created with invalid starting data.
Requirements
- Use
declare(strict_types=1);. - Use a constructor.
- Require a non-empty SKU.
- Require a non-negative unit price in pennies.
- Require a quantity of at least
1. - Add a method that returns the line total in pennies.
- Print one valid line total.
- Show one invalid quantity case by catching the exception.
- Include the expected output as comments in the same PHP code block.
The point is to make invalid object state fail at construction time.
Show solution
<?php
declare(strict_types=1);
class OrderLine
{
public function __construct(
public string $sku,
public int $unitPricePennies,
public int $quantity,
) {
if (trim($sku) === '') {
throw new InvalidArgumentException('SKU is required.');
}
if ($unitPricePennies < 0) {
throw new InvalidArgumentException('Unit price must not be negative.');
}
if ($quantity < 1) {
throw new InvalidArgumentException('Quantity must be at least 1.');
}
}
public function lineTotalPennies(): int
{
return $this->unitPricePennies * $this->quantity;
}
}
$line = new OrderLine('KB-101', 2499, 2);
echo $line->lineTotalPennies() . PHP_EOL;
try {
new OrderLine('KB-101', 2499, 0);
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// 4998
// Quantity must be at least 1.
The constructor makes the required state explicit. Code that receives an OrderLine object can rely on the quantity and price rules having already been checked.