Objects Namespaces And Application Architecture

Visibility And Access Modifiers

Visibility is not about secrecy. It is about boundaries. Public members are part of the class's interface. Private members are implementation details the class controls. Protected members are available to child classes, which makes them a wider contract than private members.

Public members are the outside interface

Public methods are what other code is allowed to call.

PHP example
<?php

declare(strict_types=1);

class OrderLine
{
    public function __construct(
        private int $unitPricePennies,
        private int $quantity,
    ) {
        if ($unitPricePennies < 0 || $quantity < 1) {
            throw new InvalidArgumentException('Invalid order line.');
        }
    }

    public function lineTotalPennies(): int
    {
        return $this->unitPricePennies * $this->quantity;
    }
}

$line = new OrderLine(2499, 2);

echo $line->lineTotalPennies() . PHP_EOL;

// Prints:
// 4998

Calling code can ask for the line total, but it cannot directly change the internal price or quantity.

Private properties protect state

Private properties force changes to go through methods that can enforce rules.

PHP example
<?php

declare(strict_types=1);

class Basket
{
    private int $itemCount = 0;

    public function addItem(): void
    {
        $this->itemCount++;
    }

    public function itemCount(): int
    {
        return $this->itemCount;
    }
}

$basket = new Basket();
$basket->addItem();
$basket->addItem();

echo $basket->itemCount() . PHP_EOL;

// Prints:
// 2

There is no way for outside code to set $itemCount to -10.

Private methods hide internal steps

Private methods are useful when a public method has a smaller internal step that should not be called from outside.

PHP example
<?php

declare(strict_types=1);

class SlugGenerator
{
    public function fromTitle(string $title): string
    {
        return $this->collapseDashes(strtolower(trim($title)));
    }

    private function collapseDashes(string $value): string
    {
        return trim(preg_replace('/[^a-z0-9]+/', '-', $value) ?? '', '-');
    }
}

$generator = new SlugGenerator();

echo $generator->fromTitle(' New Product Launch! ') . PHP_EOL;

// Prints:
// new-product-launch

The public method explains the use case. The private method is just an implementation detail.

Protected is for inheritance contracts

Protected members can be used by the class and its child classes. Use them carefully because child classes can become coupled to internal details.

PHP example
<?php

declare(strict_types=1);

class Report
{
    protected function heading(string $title): string
    {
        return strtoupper($title);
    }
}

class SalesReport extends Report
{
    public function title(): string
    {
        return $this->heading('sales report');
    }
}

$report = new SalesReport();

echo $report->title() . PHP_EOL;

// Prints:
// SALES REPORT

If you are not intentionally designing for inheritance, prefer private.

Public properties are a commitment

Once other code reads or writes a public property, changing how that property works becomes harder.

PHP example
<?php

declare(strict_types=1);

class CustomerProfile
{
    public function __construct(private string $email)
    {
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            throw new InvalidArgumentException('Email is invalid.');
        }
    }

    public function email(): string
    {
        return $this->email;
    }
}

$profile = new CustomerProfile('nia@example.com');

echo $profile->email() . PHP_EOL;

// Prints:
// nia@example.com

The getter exposes the value without letting outside code replace it with an invalid email.

What to remember

Make public what other code genuinely needs. Keep implementation details private. Use protected only when child classes are part of the design. Visibility helps you preserve object rules and change internals without breaking every caller.

Visibility Defines A Change Boundary

public, protected, and private do more than control syntax. They determine which code may depend on a member and therefore how expensive that member is to change. A public method can be called by controllers, jobs, templates, plugins, and tests. Its parameters, return meaning, exceptions, and side effects form a compatibility contract.

Start with the smallest public surface that supports the use case. Keep representation details private and expose operations that preserve the object's rules. An account with credit() and debit() can reject invalid transitions; an account with a publicly writable balance cannot know why or when its state changed.

Visibility is not a security boundary. Reflection, serialization tools, native extensions, and application defects can bypass ordinary access checks. Authorization must still be enforced at trusted application boundaries. Visibility helps developers preserve design invariants; it does not make secret data safe to store or return.

Public Methods Should Describe Capabilities

A public API is easier to maintain when methods express domain operations rather than raw field manipulation. renameTo(DisplayName $name) can enforce a naming rule and record a change, while setName(string $name) says little about accepted values or lifecycle.

Avoid exposing a method only because one test wants access to an internal step. Test public behavior, or extract the step into a separate collaborator if it has an independent responsibility. A public helper added for testing becomes available to production callers and may prevent later refactoring.

Return values are part of exposure. Returning an internal mutable collection can let callers change state without the owning object. Return an immutable value, iterator, or copy as appropriate. In PHP, arrays use copy-on-write semantics, but objects inside the array can still be shared and mutable.

Private State Preserves Invariants

Private properties allow the declaring class to control every normal mutation path. This makes it possible to keep related values consistent, such as an order status and completion timestamp. Initialize private properties in the constructor and change them only through methods that check valid transitions.

Private does not mean every tiny operation needs a getter. A getter for each property often recreates a public data structure with more syntax. Expose information callers genuinely need and consider returning a purpose-built view or value object instead of the complete internal representation.

Changing a private property is usually safer than changing a public one, but persistence and serialization can expand the real compatibility surface. An ORM mapping, serializer configuration, cached serialized object, or reflection-based hydrator may know the property name. Search those integrations before renaming it.

Protected Members Are An Extension Contract

A protected property or method is available to descendants, including subclasses outside the package. That makes it harder to change than a private member. A child can depend on initialization order, mutation timing, or undocumented side effects even when no ordinary caller sees the member.

Prefer private state plus a small protected method when a class is intentionally extensible. The method can provide a stable operation while allowing the base class to reorganize storage. Document what child code may assume, whether it may call the hook directly, and which exceptions are allowed.

Do not use protected as a compromise when unsure whether something should be public or private. Open an extension point because a supported subtype needs it. Otherwise keep the member private until a real requirement appears.

Visibility And Inheritance

A child may widen visibility, such as overriding a protected method as public, but cannot narrow a public parent contract. Even legal widening should be deliberate because it gives external callers a new operation whose behavior may have been designed only as an internal hook.

Private parent methods are not overridden in the polymorphic sense. A child can declare a method with the same name, but parent code still calls its own private implementation. Relying on identical names can confuse reviewers; use distinct names or a documented protected hook when variation is intended.

Mark classes final when they are not designed for extension. This prevents consumers from treating internal protected behavior as a supported framework. An interface or composed collaborator often exposes variation with fewer assumptions about object state.

Readonly And Controlled Mutation

Readonly properties can strengthen a value object's boundary by preventing reassignment after initialization. They are not deep immutability: a readonly property that contains a mutable object still refers to an object whose internal state may change. Prefer immutable child values when the aggregate promises immutability.

A readonly public property is still a public representation commitment. Changing its name or type affects callers. Use it when transparent data is the desired API, not merely as a shortcut around methods.

For mutable domain objects, private properties and explicit transition methods usually communicate more than public readonly snapshots. Choose based on whether the object represents a value, a record-shaped message, or an entity with behavior.

Framework And Serialization Boundaries

Hydrators and serializers sometimes require public properties or setters by convention. Configure them explicitly where possible rather than weakening the domain model globally. A transport data object can have a transparent public shape and then be converted into a validated domain object.

When serializing, decide which fields are contractually exposed. An automatic serializer that includes every public getter can leak internal flags or personal data when a new method is added. Use allowlisted schemas and authorization-aware response mappers.

Templates also deserve review. A template that reaches through several public getters into nested objects becomes coupled to the domain graph. Presenters or view models can expose the exact display data and keep changes localized.

Compatibility Review

Before changing a public or protected member, search application calls, subclasses, mocks, framework configuration, generated proxies, serialized fixtures, and external package usage. Static analysis finds many calls but may not see dynamic method names or reflection.

Use additive changes when old and new callers overlap. Add a new method, migrate callers, deprecate the old method with a clear replacement, and remove it only according to the project's compatibility policy. For libraries, protected changes may require a major version even when the public method list is unchanged.

Testing Visibility Decisions

Tests should prove invariants through public operations. Construct valid objects, perform allowed transitions, and assert rejected transitions leave state unchanged. Add serialization tests when a public response or stored representation is part of the contract.

A code review can use simple questions: Which caller needs this member? Could direct access bypass a rule? Is a protected hook intentionally supported? Does the returned value expose mutable internals? Which persisted or serialized consumer survives the deployment?

Exposure Through Diagnostics

A private property can still escape through var_dump, exception context, log normalization, debugging panels, or an automatic JSON serializer. Visibility therefore has to be paired with an explicit data-exposure policy. Mark secrets and personal fields, redact them before logs, and configure serializers with allowlists.

Error messages from public methods should explain the rejected operation without printing the entire object. Tests can inspect log events and serialized responses to prove that adding a private credential or internal flag does not make it observable. This is especially important for value objects whose __toString() method may be called implicitly by templates and logging libraries.

After this lesson, you should be able to choose visibility from the intended dependency boundary, design small capability-oriented public APIs, protect state with private mutation paths, treat protected members as compatibility contracts, and verify framework and serialization behavior before changing exposed members.

Practice

Task: Protect account balance state

Create a small account balance class using visibility.

Requirements

  • Use declare(strict_types=1);.
  • Store the balance in a private property.
  • Start the balance through the constructor.
  • Reject a negative starting balance.
  • Add a public deposit() method.
  • Reject deposits less than 1.
  • Add a public balancePennies() method.
  • Print one valid balance.
  • Show one invalid deposit by catching the exception.
  • Include the expected output as comments in the same PHP code block.

The class should not expose a public writable balance property.

Show solution
PHP example
<?php

declare(strict_types=1);

class AccountBalance
{
    public function __construct(private int $balancePennies)
    {
        if ($balancePennies < 0) {
            throw new InvalidArgumentException('Starting balance cannot be negative.');
        }
    }

    public function deposit(int $pennies): void
    {
        if ($pennies < 1) {
            throw new InvalidArgumentException('Deposit must be at least 1 penny.');
        }

        $this->balancePennies += $pennies;
    }

    public function balancePennies(): int
    {
        return $this->balancePennies;
    }
}

$balance = new AccountBalance(1000);
$balance->deposit(250);

echo $balance->balancePennies() . PHP_EOL;

try {
    $balance->deposit(0);
} catch (InvalidArgumentException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// 1250
// Deposit must be at least 1 penny.

The balance is private, so outside code cannot set it directly. All changes go through methods that enforce the object's rules.