Back to blog

Architecture

Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability

Explains how functional programming ideas - pure functions, no side effects, immutable data - produce code that is dramatically easier to reason about.

  • PHP
  • Architecture
  • Functional Programming
  • Immutability
  • Clean Code

SEO Metadata

SEO Title Options

  1. Functional Thinking as a Path to Simpler Code: Pure
  2. PHP Architecture: Practical 2026 Guide
  3. Architecture Playbook: PHP Architecture

Meta Description Options

  1. Learn PHP Architecture with a practical Architecture framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
  2. Explains how functional programming ideas - pure functions, no side effects, immutable data - produce code that is dramatically easier to reason about.

URL Slug

functional-thinking-path-simpler-code-pure-functions-immutability

Focus Keyword

PHP Architecture

Additional LSI Keywords

  • Architecture
  • PHP
  • Functional Programming
  • Immutability
  • Clean Code
  • Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy
  • performance impact

Table of Contents

Article overview

PHP Architecture is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • PHP Architecture should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: PHP Architecture expert guide for Architecture]

What PHP Architecture means

PHP Architecture means applying architecture knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For architecture topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: PHP Architecture implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat PHP Architecture as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: PHP Architecture common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for PHP Architecture with input, decision boundary, implementation, tests, and production feedback. Alt: PHP Architecture concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability. Alt: PHP Architecture mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: PHP Architecture comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for PHP Architecture.]

Internal linking opportunities

Original Technical Deep Dive

Functional thinking is not about forcing PHP to look like Haskell.

It is about making code easier to reason about.

The useful parts are small:

  • A pure function returns the same result for the same input.
  • A pure function does not change outside state.
  • Immutable data cannot be quietly rewritten by another part of the program.
  • Side effects still exist, but they are pushed to explicit boundaries.

That is enough to simplify a large amount of everyday PHP code.

The short version

Functional thinking gives you a simple design pressure:

Make decisions pure.
Make effects explicit.

In practice:

HabitWhat changes
Pass inputs directlyFunctions stop reading hidden global state
Return valuesCode stops mutating arguments in surprising ways
Use readonly objectsData has a stable shape after construction
Separate commands from queriesCallers know whether a method changes state
Keep I/O at the edgeDomain rules can be tested without databases, queues, mail, or time
Avoid shared mutable arraysOne branch cannot corrupt another branch by accident

This is not ideology. It is a debugging strategy.

If a function has no hidden inputs and no hidden outputs, you can understand it locally.

Pure functions

A pure function has two properties:

Same input, same output.
No observable side effects.

This is pure:

<?php

declare(strict_types=1);

function discountCents(int $subtotalCents, int $customerOrders): int
{
    if ($customerOrders >= 20) {
        return intdiv($subtotalCents * 15, 100);
    }

    if ($customerOrders >= 5) {
        return intdiv($subtotalCents * 5, 100);
    }

    return 0;
}

You can call it once, ten times, in a test, in a CLI command, during a retry, or in a queue worker. It will not write a log, send an email, read the clock, update a record, or depend on a global setting.

This is not pure:

<?php

declare(strict_types=1);

function discountCents(User $user, int $subtotalCents): int
{
    $orders = Order::query()
        ->where('user_id', $user->id)
        ->where('status', 'paid')
        ->count();

    logger()->info('Calculated discount.', [
        'user_id' => $user->id,
        'orders' => $orders,
    ]);

    if ($orders >= config('billing.vip_order_count')) {
        return intdiv($subtotalCents * 15, 100);
    }

    return 0;
}

The second function mixes four concerns:

  • loading order history,
  • reading configuration,
  • writing logs,
  • calculating a discount.

That makes the calculation harder to test and harder to trust. If the discount looks wrong, you have to inspect the database query, config, log side effect, and arithmetic together.

The fix is not "never use Eloquent" or "never log." The fix is to move those effects outside the calculation.

Extract the decision from the effect

Start with the part that should be pure:

<?php

declare(strict_types=1);

final readonly class DiscountPolicy
{
    public function __construct(
        private int $vipOrderCount,
    ) {
        if ($vipOrderCount < 1) {
            throw new InvalidArgumentException('VIP order count must be positive.');
        }
    }

    public function discountCents(int $subtotalCents, int $paidOrderCount): int
    {
        if ($subtotalCents < 0) {
            throw new InvalidArgumentException('Subtotal cannot be negative.');
        }

        if ($paidOrderCount >= $this->vipOrderCount) {
            return intdiv($subtotalCents * 15, 100);
        }

        if ($paidOrderCount >= 5) {
            return intdiv($subtotalCents * 5, 100);
        }

        return 0;
    }
}

Then keep the side effects in an application service:

<?php

declare(strict_types=1);

final readonly class CheckoutDiscounts
{
    public function __construct(
        private OrderHistory $orders,
        private DiscountPolicy $policy,
        private AuditLog $auditLog,
    ) {
    }

    public function forCustomer(CustomerId $customerId, int $subtotalCents): int
    {
        $paidOrderCount = $this->orders->paidOrderCountFor($customerId);

        $discountCents = $this->policy->discountCents(
            subtotalCents: $subtotalCents,
            paidOrderCount: $paidOrderCount,
        );

        $this->auditLog->record('discount.calculated', [
            'customer_id' => $customerId->value,
            'paid_order_count' => $paidOrderCount,
            'discount_cents' => $discountCents,
        ]);

        return $discountCents;
    }
}

The service still does real work. It queries and logs. But the business decision is now isolated.

That isolation buys you:

  • smaller tests,
  • fewer mocks,
  • easier retries,
  • clearer review,
  • safer refactoring.

[IMAGE: Supporting visual 1 for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability, showing PHP Architecture decisions, examples, and PHP, Architecture, Functional Programming. Alt: PHP Architecture functional-thinking-path-simpler-code-pure-functions-immutability visual 1]

[IMAGE: Supporting visual 1 for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability, showing PHP Architecture decisions, examples, and PHP, Architecture, Functional Programming. Alt: PHP Architecture functional-thinking-path-simpler-code-pure-functions-immutability visual 1]

Pure does not mean useless

Real applications need side effects:

write rows
send mail
charge cards
publish events
read files
call APIs
inspect time
generate random tokens

Functional thinking does not remove these things. It stops them from leaking into every line of business logic.

A useful architecture is often:

HTTP controller
    validates and maps input
Application service
    loads data, calls pure domain rules, saves results, emits effects
Domain policy or function
    calculates decisions from explicit inputs
Infrastructure adapter
    talks to database, queue, mail, payment provider, filesystem

The domain core should be boring. That is the point.

Example: mixed checkout logic

Before:

<?php

declare(strict_types=1);

final class CheckoutService
{
    public function complete(int $cartId): Order
    {
        $cart = Cart::query()->with('items.product')->findOrFail($cartId);

        $subtotal = 0;

        foreach ($cart->items as $item) {
            if (! $item->product->is_active) {
                throw new RuntimeException('Inactive product.');
            }

            $subtotal += $item->quantity * $item->product->price_cents;
        }

        $discount = 0;

        if ($cart->customer->orders()->where('status', 'paid')->count() >= 10) {
            $discount = intdiv($subtotal * 10, 100);
        }

        $total = $subtotal - $discount;

        $order = Order::query()->create([
            'customer_id' => $cart->customer_id,
            'subtotal_cents' => $subtotal,
            'discount_cents' => $discount,
            'total_cents' => $total,
        ]);

        Mail::to($cart->customer->email)->send(new OrderPlacedMail($order));

        return $order;
    }
}

This is common PHP code. It also makes every change expensive.

The method answers too many questions:

  • Which products are orderable?
  • How is subtotal calculated?
  • Who receives a discount?
  • How is the order stored?
  • Which email is sent?

Pull out the pure part first.

<?php

declare(strict_types=1);

final readonly class CartLine
{
    public function __construct(
        public string $sku,
        public int $quantity,
        public int $unitPriceCents,
        public bool $active,
    ) {
        if ($quantity < 1) {
            throw new InvalidArgumentException('Quantity must be positive.');
        }

        if ($unitPriceCents < 0) {
            throw new InvalidArgumentException('Unit price cannot be negative.');
        }
    }

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

Then make the quote calculation explicit:

<?php

declare(strict_types=1);

final readonly class CheckoutQuote
{
    public function __construct(
        public int $subtotalCents,
        public int $discountCents,
        public int $totalCents,
    ) {
        if ($subtotalCents < 0 || $discountCents < 0 || $totalCents < 0) {
            throw new InvalidArgumentException('Money values cannot be negative.');
        }
    }
}

final readonly class CheckoutQuoteCalculator
{
    /**
     * @param list<CartLine> $lines
     */
    public function quote(array $lines, int $paidOrderCount): CheckoutQuote
    {
        $subtotalCents = 0;

        foreach ($lines as $line) {
            if (! $line->active) {
                throw new DomainException("Product {$line->sku} is not active.");
            }

            $subtotalCents += $line->subtotalCents();
        }

        $discountCents = $paidOrderCount >= 10
            ? intdiv($subtotalCents * 10, 100)
            : 0;

        return new CheckoutQuote(
            subtotalCents: $subtotalCents,
            discountCents: $discountCents,
            totalCents: $subtotalCents - $discountCents,
        );
    }
}

Now the application service can coordinate effects without hiding the calculation:

<?php

declare(strict_types=1);

final readonly class CheckoutService
{
    public function __construct(
        private CartRepository $carts,
        private OrderRepository $orders,
        private CustomerOrderHistory $history,
        private CheckoutQuoteCalculator $quotes,
        private OrderMailer $mailer,
    ) {
    }

    public function complete(CartId $cartId): Order
    {
        $cart = $this->carts->get($cartId);
        $paidOrderCount = $this->history->paidOrderCountFor($cart->customerId);

        $quote = $this->quotes->quote(
            lines: $cart->lines,
            paidOrderCount: $paidOrderCount,
        );

        $order = $this->orders->createFromQuote($cart, $quote);

        $this->mailer->sendPlaced($order);

        return $order;
    }
}

This version still creates orders and sends mail. The difference is that the business math can be understood without booting the application.

Immutability makes values boring

Mutable state creates time-based questions:

Who changed this?
When did it change?
Was it already validated before the change?
Does another object still hold the old assumption?
Can this loop mutate it twice?

Immutable values remove most of those questions.

<?php

declare(strict_types=1);

final readonly class EmailAddress
{
    public string $value;

    public function __construct(string $value)
    {
        $normalized = strtolower(trim($value));

        if (! filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Email address is invalid.');
        }

        $this->value = $normalized;
    }
}

After construction, EmailAddress is stable. A caller cannot quietly rewrite it halfway through a registration workflow.

This matters because many bugs are not bad calculations. They are calculations made against values that changed after validation.

Readonly is not deep immutability

PHP gives useful tools, but you need to understand the boundary.

Readonly properties prevent reassignment after initialization:

<?php

declare(strict_types=1);

final readonly class ReportRequest
{
    public function __construct(
        public DateTimeImmutable $from,
        public DateTimeImmutable $to,
    ) {
        if ($from > $to) {
            throw new InvalidArgumentException('Start date must be before end date.');
        }
    }
}

This is a good immutable value because DateTimeImmutable is also immutable.

This is weaker:

<?php

declare(strict_types=1);

final readonly class BadReportRequest
{
    public function __construct(
        public DateTime $from,
        public DateTime $to,
    ) {
    }
}

The properties cannot be reassigned, but the DateTime objects inside them can still be mutated.

$request->from->modify('+1 day');

Readonly prevents reassignment. It does not freeze every nested object.

The safe rule:

Readonly outside.
Immutable types inside.
No public mutable collections.

Use with-methods for change

Immutable objects do not mean nothing can change. They mean change creates a new value.

<?php

declare(strict_types=1);

final readonly class Money
{
    public function __construct(
        public int $cents,
        public string $currency,
    ) {
        if ($cents < 0) {
            throw new InvalidArgumentException('Money cannot be negative.');
        }
    }

    public function minus(self $discount): self
    {
        $this->assertSameCurrency($discount);

        if ($discount->cents > $this->cents) {
            throw new InvalidArgumentException('Discount cannot exceed amount.');
        }

        return new self(
            cents: $this->cents - $discount->cents,
            currency: $this->currency,
        );
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('Currency mismatch.');
        }
    }
}

The caller receives a new Money instance. The old value remains true forever.

That makes code easier to inspect:

$subtotal = new Money(10_000, 'EUR');
$discount = new Money(1_000, 'EUR');
$total = $subtotal->minus($discount);

No line changes $subtotal. The names stay honest.

Avoid shared mutable arrays

Associative arrays are convenient, but they are easy to corrupt.

Bad:

<?php

declare(strict_types=1);

function applyVipDiscount(array &$invoice): void
{
    $invoice['discount_cents'] = intdiv($invoice['subtotal_cents'] * 10, 100);
    $invoice['total_cents'] = $invoice['subtotal_cents'] - $invoice['discount_cents'];
}

The function mutates whatever it receives. A caller has to know that by reading the body.

Better:

<?php

declare(strict_types=1);

/**
 * @param array{subtotal_cents:int} $invoice
 * @return array{subtotal_cents:int,discount_cents:int,total_cents:int}
 */
function withVipDiscount(array $invoice): array
{
    $discountCents = intdiv($invoice['subtotal_cents'] * 10, 100);

    return [
        ...$invoice,
        'discount_cents' => $discountCents,
        'total_cents' => $invoice['subtotal_cents'] - $discountCents,
    ];
}

Even better for domain code:

<?php

declare(strict_types=1);

final readonly class InvoiceTotals
{
    public function __construct(
        public int $subtotalCents,
        public int $discountCents,
        public int $totalCents,
    ) {
    }

    public static function withVipDiscount(int $subtotalCents): self
    {
        $discountCents = intdiv($subtotalCents * 10, 100);

        return new self(
            subtotalCents: $subtotalCents,
            discountCents: $discountCents,
            totalCents: $subtotalCents - $discountCents,
        );
    }
}

[IMAGE: Supporting visual 2 for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability, showing PHP Architecture decisions, examples, and PHP, Architecture, Functional Programming. Alt: PHP Architecture functional-thinking-path-simpler-code-pure-functions-immutability visual 2]

Arrays are fine at boundaries. Value objects are better when the value has rules.

Pass time and randomness as inputs

Time and randomness are hidden inputs.

This looks simple:

<?php

declare(strict_types=1);

final class TrialPolicy
{
    public function hasExpired(UserTrial $trial): bool
    {
        return $trial->endsAt < new DateTimeImmutable();
    }
}

But the result changes depending on when the method runs.

[IMAGE: Supporting visual 2 for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability, showing PHP Architecture decisions, examples, and PHP, Architecture, Functional Programming. Alt: PHP Architecture functional-thinking-path-simpler-code-pure-functions-immutability visual 2]

Pass the clock value:

<?php

declare(strict_types=1);

final readonly class TrialPolicy
{
    public function hasExpired(UserTrial $trial, DateTimeImmutable $now): bool
    {
        return $trial->endsAt <= $now;
    }
}

The caller decides where time comes from:

$expired = $trialPolicy->hasExpired(
    trial: $trial,
    now: $clock->now(),
);

This small change makes tests deterministic and removes a common source of flaky behavior.

The same rule applies to:

  • UUID generation,
  • random tokens,
  • exchange rates,
  • feature flags,
  • tenant context,
  • current authenticated user,
  • locale.

If a decision depends on it, pass it in or wrap it behind an explicit dependency.

Commands and queries should be honest

A query should answer a question.

A command should change something.

Mixing both creates surprising APIs:

<?php

declare(strict_types=1);

final class Cart
{
    public function totalCents(): int
    {
        $this->recalculatePromotions();

        return $this->total_cents;
    }
}

totalCents() looks like a harmless query, but it mutates the cart.

Split it:

<?php

declare(strict_types=1);

final class Cart
{
    public function recalculatePromotions(PromotionRules $rules): void
    {
        // Mutates cart state explicitly.
    }

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

Or keep the cart immutable and return a recalculated version:

<?php

declare(strict_types=1);

final readonly class CartSnapshot
{
    /**
     * @param list<CartLine> $lines
     */
    public function __construct(
        public array $lines,
        public int $totalCents,
    ) {
    }

    public function withPromotions(PromotionRules $rules): self
    {
        $totalCents = $rules->totalFor($this->lines);

        return new self(
            lines: $this->lines,
            totalCents: $totalCents,
        );
    }
}

Either approach can be valid. The important part is that the method name tells the truth.

Pipelines help when they name a transformation

Functional style is often associated with array_map, array_filter, and array_reduce.

These are useful when each step is obvious:

<?php

declare(strict_types=1);

/**
 * @param list<CartLine> $lines
 * @return list<CartLine>
 */
function activeLines(array $lines): array
{
    return array_values(array_filter(
        $lines,
        static fn (CartLine $line): bool => $line->active,
    ));
}

But a long anonymous pipeline can become unreadable:

$total = array_reduce(
    array_filter($lines, fn ($line) => $line->active && $line->quantity > 0),
    fn ($sum, $line) => $sum + ($line->quantity * $line->unitPriceCents),
    0,
);

This is not automatically better than a loop.

A boring loop is often clearer:

<?php

declare(strict_types=1);

/**
 * @param list<CartLine> $lines
 */
function subtotalCents(array $lines): int
{
    $subtotalCents = 0;

    foreach ($lines as $line) {
        if (! $line->active) {
            continue;
        }

        $subtotalCents += $line->subtotalCents();
    }

    return $subtotalCents;
}

Functional thinking is about referential clarity, not showing off array_reduce.

Use a pipeline when it improves the names. Use a loop when it improves the story.

Local mutation is not the enemy

Do not turn every loop into ceremony.

This is fine:

<?php

declare(strict_types=1);

/**
 * @param list<OrderLine> $lines
 * @return array<string, int>
 */
function quantityBySku(array $lines): array
{
    $quantities = [];

    foreach ($lines as $line) {
        $quantities[$line->sku] = ($quantities[$line->sku] ?? 0) + $line->quantity;
    }

    return $quantities;
}

$quantities is local. It is created inside the function, mutated inside the function, and returned as a value.

That is very different from mutating a shared object, global array, singleton, database row, or argument passed by reference.

The practical distinction:

Mutation typeRisk
Local temporary mutationUsually low
Mutating argumentsOften surprising
Mutating shared service stateHigh
Mutating database rows inside calculationsVery high
Mutating globals or static cachesHigh unless carefully isolated

[IMAGE: Supporting visual 3 for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability, showing PHP Architecture decisions, examples, and PHP, Architecture, Functional Programming. Alt: PHP Architecture functional-thinking-path-simpler-code-pure-functions-immutability visual 3]

Functional thinking does not forbid local variables. It asks whether mutation escapes the function.

Expected failure can be a value

Throw exceptions for invalid states and infrastructure failures.

For expected business outcomes, a return value can be clearer.

<?php

declare(strict_types=1);

final readonly class ApprovalResult
{
    private function __construct(
        public bool $approved,
        public ?string $reason,
    ) {
    }

    public static function approved(): self
    {
        return new self(true, null);
    }

    public static function rejected(string $reason): self
    {
        return new self(false, $reason);
    }
}

final readonly class PurchaseApprovalPolicy
{
    public function approve(Customer $customer, Money $total): ApprovalResult
    {
        if ($customer->isSuspended) {
            return ApprovalResult::rejected('Customer is suspended.');
        }

        if ($total->cents > $customer->creditLimitCents) {
            return ApprovalResult::rejected('Credit limit exceeded.');
        }

        return ApprovalResult::approved();
    }
}

This keeps expected rejection inside the normal return path. The caller can decide whether to show an error, create a pending review, or log a risk event.

Do not make every function return a custom result object. Use this when the outcome is part of the domain language.

[IMAGE: Supporting visual 3 for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability, showing PHP Architecture decisions, examples, and PHP, Architecture, Functional Programming. Alt: PHP Architecture functional-thinking-path-simpler-code-pure-functions-immutability visual 3]

Tests become smaller

Pure functions are cheap to test because there is almost nothing to set up.

<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class CheckoutQuoteCalculatorTest extends TestCase
{
    public function test_vip_customer_receives_discount(): void
    {
        $calculator = new CheckoutQuoteCalculator();

        $quote = $calculator->quote(
            lines: [
                new CartLine(
                    sku: 'BOOK-1',
                    quantity: 2,
                    unitPriceCents: 2_500,
                    active: true,
                ),
            ],
            paidOrderCount: 10,
        );

        self::assertSame(5_000, $quote->subtotalCents);
        self::assertSame(500, $quote->discountCents);
        self::assertSame(4_500, $quote->totalCents);
    }
}

No database.

No container.

No HTTP request.

No factory graph.

No mocked mailer.

Those things still need tests at the application boundary, but they do not need to be present in every rule test.

The side-effect shell still needs tests

Do not hide behind pure functions and ignore integration behavior.

The application service should still prove that it coordinates effects correctly:

<?php

declare(strict_types=1);

final class CheckoutServiceTest extends TestCase
{
    public function test_checkout_creates_order_and_sends_mail(): void
    {
        $carts = new InMemoryCartRepository([
            CartFixture::withActiveBook(),
        ]);

        $orders = new InMemoryOrderRepository();
        $mailer = new FakeOrderMailer();

        $service = new CheckoutService(
            carts: $carts,
            orders: $orders,
            history: new FixedCustomerOrderHistory(paidOrderCount: 10),
            quotes: new CheckoutQuoteCalculator(),
            mailer: $mailer,
        );

        $order = $service->complete(CartFixture::activeBookId());

        self::assertSame(4_500, $order->totalCents);
        self::assertTrue($orders->contains($order->id));
        self::assertTrue($mailer->sentFor($order->id));
    }
}

The test is still small because the calculation is not tangled with persistence and mail.

Refactoring toward functional thinking

You do not need a rewrite.

Use this sequence:

  1. Find a method that both calculates and writes.
  2. Identify the business decision inside it.
  3. List every input the decision uses.
  4. Pass those inputs into a new method or class.
  5. Return a value instead of mutating the caller.
  6. Add focused tests around the pure decision.
  7. Leave persistence, mail, events, logging, and queues in the original service.
  8. Rename the original service so its side effects are obvious.

Example target:

Before:
completeCheckout($cartId)
    loads cart
    calculates total
    creates order
    sends email

After:
CheckoutQuoteCalculator::quote($lines, $paidOrderCount)
    calculates total

CheckoutService::complete($cartId)
    loads cart
    calls calculator
    creates order
    sends email

This is a low-risk refactor because the boundary behavior can stay the same while the decision becomes testable.

Where not to force it

Functional thinking becomes counterproductive when it makes simple code obscure.

Avoid:

  • wrapping every scalar in a value object,
  • replacing every loop with nested higher-order functions,
  • building a custom Option or Either type for every nullable value,
  • pretending database writes are not side effects,
  • passing twenty arguments into a "pure" function instead of naming a data object,
  • introducing immutable snapshots for entities that are naturally edited through a workflow.

[IMAGE: Supporting visual 4 for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability, showing PHP Architecture decisions, examples, and PHP, Architecture, Functional Programming. Alt: PHP Architecture functional-thinking-path-simpler-code-pure-functions-immutability visual 4]

The goal is simpler reasoning, not functional branding.

Use the technique where state and side effects currently make code hard to understand.

Functional core, imperative shell

A useful phrase for PHP architecture is:

functional core, imperative shell

The core is pure:

  • pricing,
  • eligibility,
  • validation decisions,
  • ranking,
  • matching,
  • parsing,
  • policy evaluation,
  • projection from events to read models.

The shell is imperative:

  • controller actions,
  • console commands,
  • queue jobs,
  • database repositories,
  • mailers,
  • HTTP clients,
  • file storage,
  • transactions.

The shell asks the world for data, passes plain values into the core, then applies the result back to the world.

[IMAGE: Supporting visual 4 for Functional Thinking as a Path to Simpler Code: Pure Functions & Immutability, showing PHP Architecture decisions, examples, and PHP, Architecture, Functional Programming. Alt: PHP Architecture functional-thinking-path-simpler-code-pure-functions-immutability visual 4]

That separation is one of the simplest ways to keep PHP applications maintainable as they grow.

Code review checklist

When reviewing PHP code, ask:

QuestionBetter direction
Does this method both calculate and write?Extract the calculation
Does this method read time, config, auth, or globals directly?Pass the value or dependency explicitly
Does this function mutate an argument?Return a new value
Does a query method change state?Split query from command
Does readonly contain mutable objects?Use immutable nested types
Does a test need a database for pure business math?Move the math into a pure service or value object
Does a pipeline hide the story?Use named functions or a loop
Does a value object have setters?Replace with with-methods or constructors
Are side effects hidden in constructors?Move them to explicit methods or services
Is expected business rejection thrown as an exception?Consider a result value

The practical rule

If a piece of code is hard to reason about, look for hidden state.

Usually the problem is one of these:

hidden input
hidden output
shared mutable value
side effect inside a query
side effect inside a calculation

Functional thinking gives you a direct response:

Make the input explicit.
Return the output.
Keep values stable.
Move effects to the boundary.

That is enough.

You do not need to turn your PHP application into a functional programming lecture. You need code that a developer can read, test, retry, and change without wondering what else moved behind their back.

FAQ

What is PHP Architecture?

PHP Architecture is a practical architecture topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use PHP Architecture?

Use PHP Architecture when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with PHP Architecture?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test PHP Architecture?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does PHP Architecture affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

PHP Architecture is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top