DEV Community

Cover image for From Data Dump to Domain Model: Encapsulation in PHP 8
Anas Hussain
Anas Hussain

Posted on • Edited on

From Data Dump to Domain Model: Encapsulation in PHP 8

Why Public Properties Are Slowly Destroying Your Object Design

1. Hook & Problem Statement

You're working on a Laravel project. You have a User class with a $isActive property. It's public. Easy to access, easy to set.

$user->isActive = false;
Enter fullscreen mode Exit fullscreen mode

Then, you build a system that sends emails. You have a $email property. It's public. Easy.

Then, you build a payment system. You need to check if the user is active before charging them. So you write:

if ($user->isActive) {
    $this->charge($user);
}
Enter fullscreen mode Exit fullscreen mode

Everything works. You move on to the next feature.

But then the requirements change. Now, users need to be verified before they're considered active. You need to send a verification email when they become active. You need to log when they're deactivated. You need to check if their subscription is paid before setting them to active.

Suddenly, your simple $user->isActive = false needs to trigger multiple actions. But you have 47 places in your code where you directly set isActive. Now you have to find all of them and add the new logic.

You start updating them one by one. You miss one. The system breaks. You spend 3 hours debugging. The business is unhappy. You're unhappy.

This is the problem with public properties.

They're convenient. They're fast. And they're slowly destroying your codebase. Every public property is a contract you cannot change. Every direct assignment is a bug waiting to happen.

The solution is as old as object-oriented programming itself: Encapsulation.


2. Why This Pattern/Concept Exists

The Software Engineering Problem It Solves

Encapsulation solves a fundamental problem in software design: managing complexity through information hiding.

When you expose internal state (public properties), you create a coupling between the class's internal representation and every piece of code that uses it. This coupling makes change expensive and risky.

The Pain That Existed Before

In the early days of programming (and still today in many codebases), developers treated data structures as just that—structures. They exposed everything, and everyone could modify everything.

This led to:

  1. Spaghetti Data Flow: Data could be modified from anywhere. Debugging became a nightmare because you couldn't trace where a value changed.
  2. Violated Invariants: A class had assumptions about its state (e.g., "email must be valid," "isActive and emailVerified must align"). But because state was exposed, these assumptions could be broken.
  3. Fragile Code: Adding logic to a property access required finding every place that used the property.
  4. Copy-Paste Validation: Every time you used a property, you had to validate it again. Validation was scattered across the codebase.
  5. Testing Nightmare: You couldn't control what happened when a property was set because the logic (if any) was scattered.

Why Large Applications Need It

As applications grow, the number of places that interact with an object grows exponentially. A simple property like isActive might be accessed in 50 different places. Changing its behavior means finding all 50 places.

Encapsulation centralizes the logic. The property becomes a "behavioral access point." You can add logging, validation, event dispatching, or any other logic in ONE place.

The core insight: Objects aren't just data containers. They're responsible entities that protect their own integrity. Encapsulation is how they do it.


3. Real World Analogy

The Bank Teller

Imagine a bank. You can't just walk into the vault and withdraw money (public property). Instead, you go to a teller (method) and make a request.

The Encapsulated Bank:

  • You ask the teller to withdraw $100.
  • The teller checks your balance.
  • The teller verifies your identity.
  • The teller records the transaction.
  • The teller hands you the money.

The Non-Encapsulated Bank:

  • You walk into the vault.
  • You take $100.
  • The system doesn't know.
  • There's no record.
  • Anyone can do it.

Analogy Mapping:

  • Vault: The object's internal state.
  • Teller: The object's public methods.
  • Withdrawal Request: A method call.
  • Identity Verification: Validation logic.
  • Transaction Record: Logging and event dispatching.
  • Walking into the vault: Direct property access.

Why it works: The teller protects the bank's integrity. They ensure every withdrawal is legitimate. They keep records. The vault isn't exposed; only controlled access points exist.

The same applies to your objects. Public methods are the tellers. Public properties are leaving the vault door open.


4. The Pain (Bad Design)

Let's look at a typical Laravel application that has violated encapsulation.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    // Public properties (bad practice!)
    public string $status;
    public float $total;
    public string $currency;
    public int $userId;
    public array $items = [];
    public ?string $shippingAddress;
    public ?string $billingAddress;
    public string $paymentMethod;
    public ?string $paymentIntentId;
    public array $timestamps = [];

    // Few methods, most logic is outside
}
Enter fullscreen mode Exit fullscreen mode

Now, imagine 30 different controllers and services that use this model:

// Controller 1
public function store(Request $request)
{
    $order = new Order();
    $order->status = 'pending';
    $order->total = $request->total;
    $order->currency = $request->currency;
    $order->userId = $request->user()->id;
    $order->items = $request->items;
    $order->shippingAddress = $request->shipping_address;
    $order->billingAddress = $request->billing_address;
    $order->paymentMethod = $request->payment_method;
    $order->save();

    // Process payment
    if ($order->paymentMethod === 'stripe') {
        $stripe = new StripeClient(env('STRIPE_SECRET'));
        $intent = $stripe->paymentIntents->create([
            'amount' => $order->total * 100,
            'currency' => $order->currency,
        ]);
        $order->paymentIntentId = $intent->id;
        $order->save();
    }

    return response()->json($order);
}

// Controller 2
public function cancel(Order $order)
{
    if ($order->status !== 'pending') {
        throw new Exception('Order cannot be cancelled');
    }

    // Refund if paid
    if ($order->status === 'paid') {
        $stripe = new StripeClient(env('STRIPE_SECRET'));
        $stripe->refunds->create(['payment_intent' => $order->paymentIntentId]);
    }

    $order->status = 'cancelled';
    $order->save();
}

// Service 1
public function processPayment(Order $order)
{
    if ($order->total > 0 && $order->status === 'pending') {
        // Charge the user
        $payment = new PaymentService();
        $payment->charge($order->userId, $order->total, $order->currency);
        $order->status = 'paid';
        $order->save();

        // Send confirmation
        Mail::to(User::find($order->userId)->email)->send(new OrderConfirmation($order));
    }
}

// Service 2
public function calculateShipping(Order $order)
{
    if (!$order->shippingAddress) {
        return 0;
    }

    return match($order->status) {
        'pending' => 10,
        'paid' => 5,
        default => 0,
    };
}
Enter fullscreen mode Exit fullscreen mode

Why This Is Terrible

  1. Logic Scattered: Validation, payment processing, and email sending are spread across controllers and services.
  2. No Invariant Enforcement: Nothing prevents an order from being paid without a paymentIntentId. Nothing ensures status transitions are valid.
  3. Duplicated Validation: Every controller that creates an order validates the input differently (or not at all).
  4. Duplicated Payment Logic: Payment processing logic is in controllers, services, and scattered everywhere.
  5. Impossible to Change: Want to add a cancelled status that refunds automatically? Find every place that changes status.
  6. Violates SOLID:
    • SRP: No one is responsible for the order's integrity.
    • OCP: Adding a new status requires modifying many files.
    • DIP: The model depends on nothing (it's just data), while all logic depends on the model's state.

Why Developers Write Code Like This

We write code like this because:

  • Eloquent models make properties look like public fields.
  • It's fast to prototype.
  • We think "it's just data."
  • We don't realize the cost until it's too late.
  • We haven't internalized "tell, don't ask."

5. Solution Overview

Encapsulation is the bundling of data with the methods that operate on that data, combined with restricting direct access to the data.

Core Idea

Objects should hide their internal state (properties) and expose only behavior (methods). The outside world interacts with the object through this behavior, not by directly manipulating state.

Main Participants

  1. Private Properties: The object's internal state, accessible only within the object.
  2. Public Methods: The object's behavior, accessible to the outside world.
  3. Getters/Setters (Accessors/Mutators): Controlled access to properties, often with validation or logic.
  4. Command Methods: Methods that change state in meaningful ways (e.g., pay(), cancel(), markAsPaid()).

How Objects Collaborate

Instead of:

$order->status = 'paid';
Enter fullscreen mode Exit fullscreen mode

You write:

$order->markAsPaid();
Enter fullscreen mode Exit fullscreen mode

The outside world tells the object what to do, not how to do it or what to change. This is the "tell, don't ask" principle.

Mental Model

Think of an object as a living organism, not a grocery bag.

  • Grocery bag: You open it, put things in, take things out. Anyone can reach in and grab what they want.
  • Living organism: It has a boundary (skin). To interact, you talk to it (call methods). It responds (returns values or performs actions). You can't reach in and change its heart rate directly; you tell it to "run" and it changes its heart rate internally.

Encapsulation is the skin. It protects the organism's internal organs (state) and ensures they work together correctly.

Benefits

  • Centralized Logic: Validation, logging, and event dispatching in one place.
  • Invariant Protection: The object ensures it's always in a valid state.
  • Change Isolation: Changing internal representation doesn't affect outside code.
  • Readability: The object's public methods tell you what it can do.
  • Testability: Test the object's behavior, not its internal state.

Trade-offs

  • Boilerplate: More methods (getters, setters, commands).
  • Indirection: You can't just read or write properties directly.
  • Learning Curve: Developers need to understand the "tell, don't ask" principle.

6. UML Diagram

Laravel Service Container Architecture

Dependency Injection Diagram

Diagram Explanation

  1. Order encapsulates all its state (private properties) and exposes behavior (public methods).
  2. PaymentProcessor collaborates with the order by calling its methods, not by modifying properties directly.
  3. OrderRepository persists the order as a whole.
  4. OrderController orchestrates the flow using the order's behavior.

The order is the single source of truth for its own integrity.


7. Vanilla PHP Example

Let's refactor the Order example using encapsulation.

Before Refactoring (No Encapsulation)

class BadOrder
{
    public string $status;
    public float $total;
    public string $currency;
    public int $userId;
    public array $items = [];
    public ?string $shippingAddress;
    public ?string $billingAddress;
    public string $paymentMethod;
    public ?string $paymentIntentId;
    public array $timestamps = [];
}

// Usage: scattered logic everywhere
$order = new BadOrder();
$order->status = 'pending';
$order->total = 100.00;
$order->currency = 'USD';
// ... etc.
Enter fullscreen mode Exit fullscreen mode

After Refactoring (Full Encapsulation)

class Order
{
    // Private properties: hidden from outside
    private string $status;
    private float $total;
    private string $currency;
    private int $userId;
    private array $items = [];
    private ?string $shippingAddress;
    private ?string $billingAddress;
    private string $paymentMethod;
    private ?string $paymentIntentId;
    private array $timestamps = [];

    // Constants for valid states
    private const STATUS_PENDING = 'pending';
    private const STATUS_PAID = 'paid';
    private const STATUS_SHIPPED = 'shipped';
    private const STATUS_CANCELLED = 'cancelled';

    // Valid status transitions
    private const ALLOWED_TRANSITIONS = [
        self::STATUS_PENDING => [self::STATUS_PAID, self::STATUS_CANCELLED],
        self::STATUS_PAID => [self::STATUS_SHIPPED, self::STATUS_CANCELLED],
        self::STATUS_SHIPPED => [],
        self::STATUS_CANCELLED => [],
    ];

    // Constructor: validated creation
    public function __construct(array $data)
    {
        $this->validateOrderData($data);

        $this->status = self::STATUS_PENDING;
        $this->total = $data['total'];
        $this->currency = $data['currency'] ?? 'USD';
        $this->userId = $data['user_id'];
        $this->items = $data['items'] ?? [];
        $this->shippingAddress = $data['shipping_address'] ?? null;
        $this->billingAddress = $data['billing_address'] ?? null;
        $this->paymentMethod = $data['payment_method'] ?? 'stripe';
        $this->paymentIntentId = null;
        $this->timestamps['created_at'] = now();
    }

    // Command methods: behavior, not just setters
    public function markAsPaid(string $paymentIntentId): void
    {
        if ($this->status !== self::STATUS_PENDING) {
            throw new \Exception('Only pending orders can be marked as paid');
        }

        if (empty($paymentIntentId)) {
            throw new \Exception('Payment Intent ID is required');
        }

        $this->status = self::STATUS_PAID;
        $this->paymentIntentId = $paymentIntentId;
        $this->timestamps['paid_at'] = now();
    }

    public function cancel(): void
    {
        if (!$this->canBeCancelled()) {
            throw new \Exception('Order cannot be cancelled');
        }

        $this->status = self::STATUS_CANCELLED;
        $this->timestamps['cancelled_at'] = now();
    }

    public function ship(): void
    {
        if ($this->status !== self::STATUS_PAID) {
            throw new \Exception('Only paid orders can be shipped');
        }

        if (!$this->shippingAddress) {
            throw new \Exception('Shipping address is required');
        }

        $this->status = self::STATUS_SHIPPED;
        $this->timestamps['shipped_at'] = now();
    }

    public function addItem(array $item): void
    {
        if ($this->status !== self::STATUS_PENDING) {
            throw new \Exception('Cannot add items to a non-pending order');
        }

        $this->items[] = $item;
        $this->recalculateTotal();
    }

    public function updateShippingAddress(string $address): void
    {
        if ($this->status === self::STATUS_SHIPPED) {
            throw new \Exception('Cannot change shipping address after shipping');
        }

        $this->shippingAddress = $address;
    }

    // Query methods: controlled access to state
    public function getTotal(): float
    {
        return $this->total;
    }

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

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

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

    public function getItems(): array
    {
        return $this->items;
    }

    public function getShippingAddress(): ?string
    {
        return $this->shippingAddress;
    }

    public function getPaymentIntentId(): ?string
    {
        return $this->paymentIntentId;
    }

    public function isPending(): bool
    {
        return $this->status === self::STATUS_PENDING;
    }

    public function isPaid(): bool
    {
        return $this->status === self::STATUS_PAID;
    }

    public function isShipped(): bool
    {
        return $this->status === self::STATUS_SHIPPED;
    }

    public function isCancelled(): bool
    {
        return $this->status === self::STATUS_CANCELLED;
    }

    public function canBeCancelled(): bool
    {
        return in_array($this->status, [self::STATUS_PENDING, self::STATUS_PAID]);
    }

    public function canBeShipped(): bool
    {
        return $this->status === self::STATUS_PAID && $this->shippingAddress !== null;
    }

    // Private helper methods
    private function validateOrderData(array $data): void
    {
        if (!isset($data['user_id'])) {
            throw new \Exception('User ID is required');
        }

        if (!isset($data['total']) || $data['total'] <= 0) {
            throw new \Exception('Total must be greater than 0');
        }
    }

    private function recalculateTotal(): void
    {
        $total = 0;
        foreach ($this->items as $item) {
            $total += ($item['price'] ?? 0) * ($item['quantity'] ?? 1);
        }
        $this->total = $total;
    }
}
Enter fullscreen mode Exit fullscreen mode

What We Improved

  1. State Protection: Properties are private. No one can modify them directly.
  2. Behavioral Methods: markAsPaid(), cancel(), ship() express intent, not just set values.
  3. Invariant Enforcement: The order ensures it's always in a valid state.
  4. Validation Centralization: All validation is in one place (the order itself).
  5. Query Methods: Getters provide controlled access to state.
  6. Status Transitions: Valid transitions are defined and enforced.
  7. Single Responsibility: The order is responsible for its own integrity.
  8. Testability: We can test the order's behavior, not its state.

Usage Now:

// Create an order
$order = new Order([
    'user_id' => 1,
    'total' => 100.00,
    'currency' => 'USD',
    'items' => [['name' => 'Laptop', 'price' => 100.00, 'quantity' => 1]],
]);

// Process payment
$order->markAsPaid('pi_123456');

// Cannot directly change status
// $order->status = 'paid'; // Error: private property

// Ship the order
if ($order->canBeShipped()) {
    $order->ship();
}

// Cancel only if allowed
if ($order->canBeCancelled()) {
    $order->cancel();
}
Enter fullscreen mode Exit fullscreen mode

8. Laravel Internal Example

Laravel is built on encapsulation. Let's look at examples where the framework protects internal state.

Eloquent Models (Encapsulation Through Mutators)

Eloquent models use mutators (setters) and accessors (getters) to encapsulate property access.

// Laravel's Model class
class Model
{
    // Properties are protected
    protected $attributes = [];
    protected $original = [];
    protected $hidden = [];
    protected $fillable = [];

    // Encapsulated access
    public function setAttribute($key, $value)
    {
        // Validation, casting, mutator logic
        if ($this->hasSetMutator($key)) {
            return $this->setMutatedAttributeValue($key, $value);
        }
        // ... more logic
    }

    public function getAttribute($key)
    {
        // Casting, accessor logic
        if ($this->hasGetMutator($key)) {
            return $this->mutateAttribute($key, $value);
        }
        // ... more logic
    }
}

// In your model
class User extends Model
{
    // You can add custom mutators
    public function setPasswordAttribute($value)
    {
        // Encapsulated: any password set goes through bcrypt
        $this->attributes['password'] = bcrypt($value);
    }

    public function getFullNameAttribute()
    {
        // Encapsulated: computed property
        return $this->first_name . ' ' . $this->last_name;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Is Elegant:

  • You can't directly assign invalid values.
  • Mutators centralize logic (e.g., password hashing).
  • Accessors provide computed properties.
  • The model protects its integrity.

The Service Container (Encapsulated Resolution)

class Container
{
    // Private/internal state
    private $bindings = [];
    private $instances = [];
    private $aliases = [];

    // Encapsulated access
    public function bind($abstract, $concrete = null, $shared = false)
    {
        // Validation and setup
        $this->bindings[$abstract] = compact('concrete', 'shared');
    }

    public function make($abstract, array $parameters = [])
    {
        // Complex resolution logic encapsulated
        return $this->resolve($abstract, $parameters);
    }

    // Private methods for internal logic
    private function resolve($abstract, $parameters)
    {
        // Complex resolution hidden from outside
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Is Elegant:

  • The container's internal state (bindings, instances) is private.
  • You interact through public methods (bind, make).
  • The container can change its internal implementation without affecting users.

Middleware Pipeline (Encapsulated Flow)

// Pipeline class
class Pipeline
{
    private $passable;
    private $pipes = [];
    private $method = 'handle';

    public function send($passable)
    {
        $this->passable = $passable;
        return $this;
    }

    public function through($pipes)
    {
        $this->pipes = is_array($pipes) ? $pipes : func_get_args();
        return $this;
    }

    public function then(Closure $destination)
    {
        // The pipeline controls the flow internally
        $pipeline = array_reduce(
            array_reverse($this->pipes),
            $this->carry(),
            $this->prepareDestination($destination)
        );

        return $pipeline($this->passable);
    }

    // Private helper methods
    private function carry()
    {
        return function ($stack, $pipe) {
            return function ($passable) use ($stack, $pipe) {
                // Encapsulated middleware execution
                return (new $pipe)->handle($passable, $stack);
            };
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Is Elegant:

  • The pipeline's internal flow is hidden.
  • You tell the pipeline what to send, through which pipes, and what to do at the end.
  • The pipeline encapsulates the complexity of middleware execution.

Queues (Encapsulated Job Processing)

class Worker
{
    public function processJob(Job $job)
    {
        // Encapsulated job processing
        $this->markJobAsStarted($job);

        try {
            $this->fireJob($job);
            $this->markJobAsFinished($job);
        } catch (Exception $e) {
            $this->markJobAsFailed($job, $e);
            $this->raiseFailureEvent($job, $e);
        }
    }

    // Private methods encapsulate logic
    private function markJobAsStarted(Job $job) { /* ... */ }
    private function fireJob(Job $job) { /* ... */ }
    private function markJobAsFinished(Job $job) { /* ... */ }
    private function markJobAsFailed(Job $job, Exception $e) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Why This Is Elegant:

  • The worker handles all the complexity of job lifecycle.
  • You don't need to know how to handle failures; the worker does it.
  • Changing how failures are handled only requires changing the worker.

9. Real Laravel Application Example

Let's build a Subscription Management System with full encapsulation.

Scenario

Your SaaS application needs to manage user subscriptions. Subscriptions can be:

  • Trial (7 days)
  • Monthly
  • Annual

They can be canceled, resumed, upgraded, downgraded, and have automatic renewals.

Implementation

Step 1: The Subscription Class (Fully Encapsulated)
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Subscription extends Model
{
    // Private state
    private string $status; // trial, active, paused, cancelled, expired
    private string $plan; // trial, monthly, annual
    private int $userId;
    private ?string $stripeSubscriptionId;
    private ?string $stripeCustomerId;
    private ?string $stripePaymentMethodId;
    private ?array $paymentHistory = [];
    private array $features = [];
    private array $billingCycle;

    // Constants
    private const STATUS_TRIAL = 'trial';
    private const STATUS_ACTIVE = 'active';
    private const STATUS_PAUSED = 'paused';
    private const STATUS_CANCELLED = 'cancelled';
    private const STATUS_EXPIRED = 'expired';

    private const PLAN_TRIAL = 'trial';
    private const PLAN_MONTHLY = 'monthly';
    private const PLAN_ANNUAL = 'annual';

    private const PLAN_FEATURES = [
        'trial' => ['basic_features' => true],
        'monthly' => ['basic_features' => true, 'advanced_features' => true],
        'annual' => ['basic_features' => true, 'advanced_features' => true, 'premium_support' => true],
    ];

    private const PLAN_PRICES = [
        'monthly' => 9.99,
        'annual' => 99.99,
    ];

    // Constructor
    public function __construct(int $userId, string $plan = self::PLAN_TRIAL)
    {
        $this->userId = $userId;
        $this->plan = $plan;
        $this->status = self::STATUS_TRIAL;
        $this->features = self::PLAN_FEATURES[$plan] ?? [];
        $this->billingCycle = [
            'started_at' => now(),
            'next_billing_at' => now()->addDays(7),
        ];
    }

    // Command methods
    public function activateTrial(): void
    {
        if ($this->status !== self::STATUS_TRIAL) {
            throw new \Exception('Trial can only be activated from trial status');
        }

        // Set trial features
        $this->features = self::PLAN_FEATURES['trial'];
        $this->billingCycle['trial_end'] = now()->addDays(7);
        $this->status = self::STATUS_TRIAL;
    }

    public function subscribe(string $stripeCustomerId, string $stripePaymentMethodId): void
    {
        if (!in_array($this->status, [self::STATUS_TRIAL, self::STATUS_CANCELLED])) {
            throw new \Exception('Cannot subscribe from current status');
        }

        if ($this->plan === self::PLAN_TRIAL) {
            throw new \Exception('Cannot subscribe with trial plan');
        }

        $this->stripeCustomerId = $stripeCustomerId;
        $this->stripePaymentMethodId = $stripePaymentMethodId;
        $this->status = self::STATUS_ACTIVE;
        $this->features = self::PLAN_FEATURES[$this->plan];
        $this->billingCycle = [
            'started_at' => now(),
            'next_billing_at' => $this->calculateNextBillingDate(),
        ];
        $this->paymentHistory[] = [
            'date' => now(),
            'amount' => $this->getPrice(),
            'type' => 'subscription_start',
        ];
    }

    public function cancel(): void
    {
        if (!in_array($this->status, [self::STATUS_ACTIVE, self::STATUS_PAUSED])) {
            throw new \Exception('Subscription cannot be cancelled');
        }

        $this->status = self::STATUS_CANCELLED;
        $this->billingCycle['cancelled_at'] = now();

        // Note: When to end depends on billing cycle
        $this->billingCycle['ends_at'] = $this->billingCycle['next_billing_at'] ?? now();
    }

    public function pause(): void
    {
        if ($this->status !== self::STATUS_ACTIVE) {
            throw new \Exception('Only active subscriptions can be paused');
        }

        $this->status = self::STATUS_PAUSED;
        $this->billingCycle['paused_at'] = now();
    }

    public function resume(): void
    {
        if ($this->status !== self::STATUS_PAUSED) {
            throw new \Exception('Only paused subscriptions can be resumed');
        }

        $this->status = self::STATUS_ACTIVE;
        // Reset billing cycle
        $this->billingCycle['next_billing_at'] = now()->addMonths(1);
        unset($this->billingCycle['paused_at']);
    }

    public function changePlan(string $newPlan): void
    {
        if (!in_array($this->status, [self::STATUS_ACTIVE, self::STATUS_PAUSED])) {
            throw new \Exception('Subscription must be active or paused to change plan');
        }

        if (!isset(self::PLAN_FEATURES[$newPlan])) {
            throw new \Exception('Invalid plan');
        }

        if ($newPlan === $this->plan) {
            throw new \Exception('New plan must be different');
        }

        // Store old plan for proration
        $oldPlan = $this->plan;
        $this->plan = $newPlan;
        $this->features = self::PLAN_FEATURES[$newPlan];

        // If upgrading, charge the difference
        if ($this->getPrice($newPlan) > $this->getPrice($oldPlan)) {
            $this->paymentHistory[] = [
                'date' => now(),
                'amount' => $this->getPrice($newPlan) - $this->getPrice($oldPlan),
                'type' => 'upgrade_proration',
            ];
        }

        $this->billingCycle['next_billing_at'] = $this->calculateNextBillingDate();
    }

    public function processRenewal(): void
    {
        if ($this->status !== self::STATUS_ACTIVE) {
            throw new \Exception('Only active subscriptions can be renewed');
        }

        // Charge the user
        // This would call Stripe, but we'll just update our records

        $this->paymentHistory[] = [
            'date' => now(),
            'amount' => $this->getPrice(),
            'type' => 'renewal',
        ];

        $this->billingCycle['last_renewal_at'] = now();
        $this->billingCycle['next_billing_at'] = $this->calculateNextBillingDate();
    }

    // Query methods
    public function isActive(): bool
    {
        return in_array($this->status, [self::STATUS_TRIAL, self::STATUS_ACTIVE]);
    }

    public function isOnTrial(): bool
    {
        return $this->status === self::STATUS_TRIAL;
    }

    public function canBeCancelled(): bool
    {
        return in_array($this->status, [self::STATUS_ACTIVE, self::STATUS_PAUSED]);
    }

    public function canBePaused(): bool
    {
        return $this->status === self::STATUS_ACTIVE;
    }

    public function canBeResumed(): bool
    {
        return $this->status === self::STATUS_PAUSED;
    }

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

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

    public function getPrice(?string $plan = null): float
    {
        $plan = $plan ?? $this->plan;
        return self::PLAN_PRICES[$plan] ?? 0;
    }

    public function getFeatures(): array
    {
        return $this->features;
    }

    public function hasFeature(string $feature): bool
    {
        return $this->features[$feature] ?? false;
    }

    public function getNextBillingDate(): ?string
    {
        return $this->billingCycle['next_billing_at'] ?? null;
    }

    public function getPaymentHistory(): array
    {
        return $this->paymentHistory;
    }

    // Private helpers
    private function calculateNextBillingDate(): string
    {
        return match($this->plan) {
            self::PLAN_MONTHLY => now()->addMonths(1)->toDateString(),
            self::PLAN_ANNUAL => now()->addYear()->toDateString(),
            default => now()->addDays(30)->toDateString(),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode
Step 2: Service Provider (for dependency injection)
namespace App\Providers;

use App\Models\Subscription;
use Illuminate\Support\ServiceProvider;

class SubscriptionServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(Subscription::class, function ($app) {
            // You could add initialization logic here
            return new Subscription(
                $app->request->user()->id,
                $app->config->get('subscription.default_plan', 'monthly')
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
Step 3: Controller (Using Encapsulation)
namespace App\Http\Controllers;

use App\Models\Subscription;
use Illuminate\Http\Request;

class SubscriptionController extends Controller
{
    public function subscribe(Request $request)
    {
        $subscription = new Subscription(
            $request->user()->id,
            $request->input('plan', 'monthly')
        );

        // Validate payment method
        $request->validate([
            'payment_method_id' => 'required|string',
            'stripe_customer_id' => 'required|string',
        ]);

        try {
            $subscription->subscribe(
                $request->input('stripe_customer_id'),
                $request->input('payment_method_id')
            );

            // Persist the subscription (using a repository)
            $subscription->save();

            return response()->json([
                'subscription' => [
                    'plan' => $subscription->getPlan(),
                    'status' => $subscription->getStatus(),
                    'features' => $subscription->getFeatures(),
                    'next_billing' => $subscription->getNextBillingDate(),
                ]
            ]);
        } catch (\Exception $e) {
            return response()->json(['error' => $e->getMessage()], 400);
        }
    }

    public function cancel(Subscription $subscription)
    {
        if (!$subscription->canBeCancelled()) {
            return response()->json(['error' => 'Subscription cannot be cancelled'], 400);
        }

        $subscription->cancel();
        $subscription->save();

        return response()->json([
            'message' => 'Subscription cancelled',
            'status' => $subscription->getStatus(),
        ]);
    }

    public function changePlan(Request $request, Subscription $subscription)
    {
        try {
            $subscription->changePlan($request->input('plan'));
            $subscription->save();

            return response()->json([
                'plan' => $subscription->getPlan(),
                'features' => $subscription->getFeatures(),
            ]);
        } catch (\Exception $e) {
            return response()->json(['error' => $e->getMessage()], 400);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Encapsulation Wins Here

  1. State Protection: You can't accidentally put the subscription in an invalid state.
  2. Intent-Revealing Methods: activateTrial(), subscribe(), pause(), resume() express clear intent.
  3. Centralized Validation: All validation is in the subscription class.
  4. Event-Ready: Adding events (e.g., SubscriptionCreated, PlanChanged) is trivial.
  5. Testability: Test each method in isolation with known inputs.
  6. Domain Language: The class speaks the language of the business (trial, pause, resume, upgrade).

10. SOLID Principles Mapping

S - Single Responsibility Principle (SRP)

Encapsulation naturally enforces SRP. The Subscription class is responsible for subscription integrity. It doesn't handle HTTP requests, database persistence, or email sending. Those are separate concerns.

O - Open/Closed Principle (OCP)

The Subscription class is open for extension (adding new plans, statuses) but closed for modification (you don't change existing methods to add new behavior).

L - Liskov Substitution Principle (LSP)

When you encapsulate, you work with abstractions. Subclasses (if any) can substitute the parent class because the parent's behavior is defined by its public methods, not its exposed state.

I - Interface Segregation Principle (ISP)

Encapsulation encourages focused interfaces. Each public method is a clear, focused behavior. You don't have to implement methods you don't need.

D - Dependency Inversion Principle (DIP)

High-level modules depend on abstractions, not concrete state. With encapsulation, you depend on the object's behavior (methods), not its internal representation.


11. Trade-offs

Benefits

  1. Integrity Protection: Objects ensure they're always in a valid state.
  2. Change Isolation: Internal changes don't affect external code.
  3. Centralized Logic: Validation, logging, and events in one place.
  4. Readability: Methods like cancel() are self-documenting.
  5. Testability: Test behavior, not state.
  6. Debugability: You know where state changes happen (in the object's methods).

Costs

  1. Boilerplate: More methods to write (getters, setters, commands).
  2. Indirection: You can't directly read or write properties.
  3. Learning Curve: Developers must learn to "tell, don't ask."
  4. Performance: Method calls have a tiny overhead vs property access (negligible in practice).

When Is Complexity Justified?

Encapsulation is justified for any class that has behavior. For pure data containers (DTOs), you might use public properties with readonly in PHP 8.1.

Rule of thumb: If the class has methods besides getters/setters, encapsulate. If it's just a data bag, consider using a DTO.


12. When NOT To Use It

3 Green Flags (USE ENCAPSULATION)

  1. Business Logic: Classes that enforce rules and invariants.
  2. State Management: Classes where state changes have consequences (logging, events, validation).
  3. Complex Objects: Objects with multiple related properties that must stay in sync.

3 Red Flags (AVOID OVER-ENCAPSULATION)

  1. Data Transfer Objects (DTOs): Simple containers for moving data between layers. Use readonly public properties.
  2. Value Objects: Immutable objects like Money, Email, PhoneNumber. They can be public if they're immutable.
  3. Config Objects: Objects that hold configuration values and have no behavior.

13. Common Mistakes

1. Getters and Setters for Everything

// BAD: Wrapping every property with getter/setter
class User
{
    private string $name;

    public function getName(): string { return $this->name; }
    public function setName(string $name): void { $this->name = $name; }
}
Enter fullscreen mode Exit fullscreen mode

Problem: This is just verbose property access. It adds no value.

Fix: Use readonly public properties or add behavior to the methods.

// GOOD: Behavior, not just setters
class User
{
    private string $name;

    public function changeName(string $newName): void
    {
        // Validation, events, and more
        if (strlen($newName) < 2) {
            throw new \Exception('Name is too short');
        }

        $this->name = $newName;
        $this->recordEvent(new NameChangedEvent($this->id, $newName));
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Exposing Internal State Anyway

// BAD: Exposing internal state through getters
class Order
{
    private array $items = [];

    public function getItems(): array
    {
        return $this->items; // Exposed!
    }
}

// Outside code can modify the array
$items = $order->getItems();
$items[] = ['name' => 'New Item']; // Breaking encapsulation!
Enter fullscreen mode Exit fullscreen mode

Problem: You've exposed a mutable array. Outside code can modify it.

Fix: Return immutable representations.

class Order
{
    private array $items = [];

    public function getItems(): array
    {
        return array_map(fn($item) => clone $item, $this->items); // Return copies
    }

    // Or return a DTO
    public function getItems(): ItemCollection
    {
        return new ItemCollection($this->items);
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Getting and Then Setting Instead of Telling

// BAD: Ask, then set
if ($order->getStatus() === 'pending') {
    $order->setStatus('paid');
    $order->setPaymentIntentId($intentId);
}

// GOOD: Tell the object
$order->markAsPaid($intentId);
Enter fullscreen mode Exit fullscreen mode

Problem: The object has no control over the transition. It's just reacting.

Fix: Command methods express intent and enforce invariants.

4. Violating Law of Demeter (Talking Too Much)

// BAD: Breaking encapsulation by traversing objects
$user->getAddress()->getCity()->getName();

// GOOD: Tell the user what you want
$user->getCityName();
Enter fullscreen mode Exit fullscreen mode

Problem: You're reaching deep into object graphs. You're coupling to internal structures.

Fix: Each object should provide methods that handle its own responsibilities.

5. Validation in the Wrong Place

// BAD: Validation outside the object
if (strlen($data['email']) > 0 && filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
    $user->setEmail($data['email']);
}

// GOOD: Validation inside the object
$user->setEmail($data['email']); // Object validates
Enter fullscreen mode Exit fullscreen mode

Problem: Validation is scattered. It's easy to miss.

Fix: Validate in the object. The object is the guardian of its own data.


14. Frequently Asked Interview Questions

Beginner/Intermediate

  1. Q: What is encapsulation in object-oriented programming?
    A: The bundling of data with methods that operate on that data, with restricted direct access to the data.

  2. Q: Why should I make properties private instead of public?
    A: To protect the object's integrity, centralize validation, and make the code easier to maintain and change.

  3. Q: What's the difference between a getter and a public property?
    A: A getter can compute a value, validate access, or return a transformed value. A public property is just a direct reference to internal state.

  4. Q: What does "tell, don't ask" mean?
    A: You should tell objects what to do rather than asking them for data and then making decisions based on that data.

  5. Q: How does Laravel's Eloquent support encapsulation?
    A: Through mutators (setters) and accessors (getters) that allow you to add logic when properties are set or read.

Senior/Architect

  1. Q: Explain the relationship between encapsulation and the Law of Demeter.
    A: The Law of Demeter (Don't Talk to Strangers) states that objects should only talk to their immediate friends. Encapsulation enforces this by hiding internal structure, preventing chained method calls like $user->getAddress()->getCity()->getName().

  2. Q: How do you design for encapsulation while maintaining performance?
    A: Focus on designing clear, focused interfaces. Use CQRS (Command Query Responsibility Segregation) to separate commands (state-changing methods) from queries (read-only methods). Use DTOs for efficient data transfer.

  3. Q: What's the difference between encapsulation and information hiding?
    A: Encapsulation is the broader concept of bundling data and behavior. Information hiding is the specific practice of hiding internal state and implementation details.

  4. Q: How does encapsulation work with dependency injection?
    A: Encapsulation ensures that dependencies are injected through the constructor (or setters) rather than created internally. This allows the object to control its own state while accepting external dependencies.

  5. Q: How do you refactor a codebase with public properties to use encapsulation?
    A: Gradually make properties private, add getters/setters, and update external code to use the methods. Then, identify behavioral patterns and create command methods (like markAsPaid()) to replace the "get, then set" pattern.


15. Interactive Practice Challenge

The Requirement

You're building an Inventory Management System. The current code uses public properties everywhere, making it fragile and difficult to maintain.

The Code (POOR DESIGN)

// InventoryItem.php
class InventoryItem
{
    public string $sku;
    public string $name;
    public int $quantity;
    public int $minStockLevel;
    public int $maxStockLevel;
    public float $price;
    public ?string $supplier;
    public ?string $location;
    public string $status;
    public array $movementHistory = [];
}

// InventoryService.php
class InventoryService
{
    public function addStock(InventoryItem $item, int $quantity, string $reason): void
    {
        if ($quantity <= 0) {
            throw new \Exception('Quantity must be positive');
        }

        $oldQuantity = $item->quantity;
        $item->quantity += $quantity;
        $item->movementHistory[] = [
            'type' => 'add',
            'quantity' => $quantity,
            'reason' => $reason,
            'old_quantity' => $oldQuantity,
            'new_quantity' => $item->quantity,
            'timestamp' => now(),
        ];

        if ($item->quantity > $item->maxStockLevel) {
            Log::warning('Stock exceeded max level', ['sku' => $item->sku]);
        }
    }

    public function removeStock(InventoryItem $item, int $quantity, string $reason): void
    {
        if ($quantity <= 0) {
            throw new \Exception('Quantity must be positive');
        }

        if ($item->quantity - $quantity < 0) {
            throw new \Exception('Insufficient stock');
        }

        $oldQuantity = $item->quantity;
        $item->quantity -= $quantity;
        $item->movementHistory[] = [
            'type' => 'remove',
            'quantity' => $quantity,
            'reason' => $reason,
            'old_quantity' => $oldQuantity,
            'new_quantity' => $item->quantity,
            'timestamp' => now(),
        ];

        if ($item->quantity < $item->minStockLevel) {
            Log::warning('Stock below min level', ['sku' => $item->sku]);
            // Send reorder notification
            $this->reorderItem($item);
        }
    }

    public function reorderItem(InventoryItem $item): void
    {
        if (!$item->supplier) {
            Log::error('No supplier for item', ['sku' => $item->sku]);
            return;
        }

        $reorderQuantity = $item->maxStockLevel - $item->quantity;
        if ($reorderQuantity > 0) {
            // Send reorder request to supplier
            Log::info('Reordering item', [
                'sku' => $item->sku,
                'supplier' => $item->supplier,
                'quantity' => $reorderQuantity,
            ]);

            $item->movementHistory[] = [
                'type' => 'reorder',
                'quantity' => $reorderQuantity,
                'timestamp' => now(),
            ];
        }
    }
}

// OrderProcessor.php
class OrderProcessor
{
    public function processOrder(array $items): void
    {
        foreach ($items as $itemData) {
            $item = InventoryItem::find($itemData['sku']);

            if (!$item) {
                continue;
            }

            // Check if available
            if ($item->quantity < $itemData['quantity']) {
                throw new \Exception("Insufficient stock for {$item->sku}");
            }

            // Check if we're near max
            if ($item->quantity > $item->maxStockLevel - 10) {
                Log::warning("Stock is high for {$item->sku}");
            }

            // Decrement
            $item->quantity -= $itemData['quantity'];

            // Update history
            $item->movementHistory[] = [
                'type' => 'order_fulfillment',
                'quantity' => $itemData['quantity'],
                'timestamp' => now(),
            ];

            // Save
            $item->save();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Challenges

The code has become a nightmare to maintain. New requirements keep coming:

  1. "We need to track who made each inventory change."
  2. "We need to notify the warehouse when an item is low."
  3. "We need to put items on hold (reserve stock) for pending orders."
  4. "We need to calculate the total value of inventory."
  5. "We need to auto-approve reorders for high-value items."

Your Task

Refactor this system using encapsulation. Specifically:

  1. Make properties private and add behavior through methods.
  2. Create command methods like addStock(), removeStock(), reserveStock() that enforce invariants.
  3. Move all validation into the InventoryItem class.
  4. Centralize logging and event dispatching within the object.
  5. Add query methods to safely expose data.
  6. Refactor external code to "tell" the object what to do rather than "asking" it for data.

Questions to Consider

  • What should happen when stock exceeds max?
  • How do you handle stock reservations for pending orders?
  • How do you track who made each change?
  • How do you notify the warehouse when stock is low?
  • How do you calculate total inventory value?
  • What methods should InventoryItem expose?

(We won't provide the solution—refactor this code and see how much cleaner it becomes!)


16. Final Mental Model

To keep it simple, memorize these three sentences:

  • One-sentence definition: Encapsulation is the principle of hiding internal state and exposing behavior, ensuring that objects maintain their own integrity.
  • One-sentence intuition: Objects are like bank vaults—you can't access them directly, but you can ask the teller (the public methods) to perform operations.
  • One-sentence decision rule: If a class has behavior (not just data), make its properties private and expose methods that represent what the object can do, not what it contains.

17. Related Concepts

SOLID Principles

  • Single Responsibility: Encapsulation ensures each object is responsible for its own state.
  • Open/Closed: Objects are open for extension but closed for modification through encapsulation.
  • Liskov Substitution: Subclasses can substitute because behavior, not state, defines the contract.
  • Interface Segregation: Focused interfaces emerge from encapsulated behavior.
  • Dependency Inversion: High-level modules depend on behavior abstractions, not state.

Design Patterns

  • Command Pattern: Encapsulates a request as an object (like markAsPaid()).
  • Strategy Pattern: Encapsulates algorithms behind an interface.
  • Observer Pattern: Encapsulates event notification and handling.
  • State Pattern: Encapsulates state-specific behavior.
  • Factory Pattern: Encapsulates object creation logic.

Laravel Internals

  • Eloquent Models: Use mutators/accessors to encapsulate property access.
  • Service Container: Encapsulates dependency resolution logic.
  • Event System: Encapsulates event dispatching and listener resolution.
  • Middleware: Encapsulates request/response processing steps.

Enterprise Patterns

  • Domain Model: Encapsulates business rules and logic.
  • Value Object: Immutable objects that encapsulate validation.
  • Aggregate Root: Encapsulates consistency boundaries.
  • Repository: Encapsulates data access logic.

Final Thoughts

Public properties are the silent killer of object-oriented design. They seem convenient, but they slowly erode your codebase's integrity, making changes expensive and risky.

Encapsulation is your shield. It protects your objects from being corrupted, centralizes logic, and makes your code maintainable and testable.

The next time you start typing public $property, ask yourself: "Do I really want everyone to be able to change this? Or should this object be responsible for its own state?"

Remember: A good object tells the world what it can do, not what it contains. Objects are actors, not data containers.


Github: Encapsulation

Top comments (0)