SEO Metadata
SEO Title Options
- Functional Thinking as a Path to Simpler Code: Pure
- PHP Architecture: Practical 2026 Guide
- Architecture Playbook: PHP Architecture
Meta Description Options
- Learn PHP Architecture with a practical Architecture framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
- 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
- What PHP Architecture means
- Why it matters now
- Implementation framework
- Practical comparison
- Expert workflow
- Common mistakes
- Media and link plan
- Original technical deep dive
- FAQ
- Structured data
- Conclusion
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.
- Define the user problem and the production risk.
- Identify the smallest reliable implementation boundary.
- Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
- Add tests for the behavior that would hurt if it regressed.
- Document the trade-off, not only the final code.
- Measure the result with logs, metrics, or user-facing outcomes.
- 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 area | Strong approach | Weak approach | Why it matters |
|---|---|---|---|
| Scope | Solve one clear problem | Mix unrelated concerns | Focus improves testing and search intent |
| Architecture | Put logic in explicit classes or documented boundaries | Hide behavior in templates or incidental callbacks | Future changes stay easier to review |
| Data flow | Pass prepared data into the view or endpoint | Query or compute in presentation code | Reduces regressions and performance surprises |
| Testing | Cover the risky behavior directly | Test only the happy path | Catches production failures earlier |
| Documentation | Explain trade-offs and limits | Repeat generic definitions | Builds E-E-A-T and reader trust |
| Operations | Track logs, metrics, and rollback steps | Ship without measurement | Makes 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]
Media and link plan
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.]
Trustworthy outbound links
- PHP manual - use this as the trust reference for language-level reference.
- Google Search quality guidance - use this as the trust reference for people-first content and E-E-A-T alignment.
Internal linking opportunities
- Internal guide: PHP Data Transfer Objects: Validation - use this when readers need a related Architecture follow-up.
- Internal guide: SOLID Principles in PHP: Practical Examples - use this when readers need a related Architecture follow-up.
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:
| Habit | What changes |
|---|---|
| Pass inputs directly | Functions stop reading hidden global state |
| Return values | Code stops mutating arguments in surprising ways |
| Use readonly objects | Data has a stable shape after construction |
| Separate commands from queries | Callers know whether a method changes state |
| Keep I/O at the edge | Domain rules can be tested without databases, queues, mail, or time |
| Avoid shared mutable arrays | One 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:
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:
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:
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:
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:
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.
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:
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:
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.
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:
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:
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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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 type | Risk |
|---|---|
| Local temporary mutation | Usually low |
| Mutating arguments | Often surprising |
| Mutating shared service state | High |
| Mutating database rows inside calculations | Very high |
| Mutating globals or static caches | High 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.
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.
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:
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:
- Find a method that both calculates and writes.
- Identify the business decision inside it.
- List every input the decision uses.
- Pass those inputs into a new method or class.
- Return a value instead of mutating the caller.
- Add focused tests around the pure decision.
- Leave persistence, mail, events, logging, and queues in the original service.
- 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
OptionorEithertype 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:
| Question | Better 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.