PHP Language Basics

Milestone: Control Flow

This milestone combines the language features from the previous lessons. The goal is not to introduce another syntax feature. It is to prove that you can divide a small workflow into understandable rules, trace every path, and preserve clear ownership of state.

The example processes order records. Each order must have a usable ID, be paid, and have a positive total before it can move to shipping. Rejected orders receive a specific reason, and the script prints a final summary.

Define The Policy Before Writing The Loop

Write the rules in plain language first:

  1. a missing or non-positive ID means the record is invalid;
  2. only the exact status paid can ship;
  3. a missing, zero, or negative total cannot ship;
  4. every order produces one result line;
  5. shipped and skipped counts must equal the number of processed orders.

These rules define fallback behavior for missing keys. The script does not pretend missing data is valid, and it does not rely on warnings to choose a path.

Give Repeated Questions Names

Small boolean functions make each rule inspectable:

PHP example
<?php

function hasValidOrderId(array $order): bool
{
    return ($order['id'] ?? 0) > 0;
}

function isPaidOrder(array $order): bool
{
    return ($order['status'] ?? '') === 'paid';
}

function hasPositiveTotal(array $order): bool
{
    return ($order['total_cents'] ?? 0) > 0;
}

Each function answers one question and returns a boolean. The ?? fallbacks are deliberate policy:

  • a missing ID becomes 0, which is invalid;
  • a missing status becomes an empty string, which is not paid;
  • a missing total becomes 0, which is not positive.

A fallback prevents an undefined-key warning, but it does not make incomplete data acceptable. It translates missing data into the rule's rejected state.

Keep Status Mapping Separate

A separate function maps exact status values to review messages:

PHP example
<?php

function statusMessage(string $status): string
{
    return match ($status) {
        'paid' => 'Ready to ship',
        'pending' => 'Waiting for payment',
        'failed' => 'Payment failed',
        'cancelled' => 'Order cancelled',
        default => 'Unknown status',
    };
}

match is appropriate because one exact string maps to one returned string. The default arm makes the function exhaustive for unexpected values such as refunded or a spelling mistake.

This function describes a status. It does not decide whether an entire order is valid, mutate counters, or print output. Keeping those responsibilities separate makes each rule easier to test.

Process Every Order Through Ordered Guards

Now combine the functions in one loop:

PHP example
<?php

$orders = [
    ['id' => 701, 'status' => 'paid', 'total_cents' => 2500],
    ['id' => 702, 'status' => 'pending', 'total_cents' => 1800],
    ['id' => 703, 'status' => 'paid', 'total_cents' => 0],
    ['status' => 'paid', 'total_cents' => 900],
    ['id' => 705, 'status' => 'refunded', 'total_cents' => 1200],
];

$shippedCount = 0;
$skippedCount = 0;

foreach ($orders as $order) {
    if (!hasValidOrderId($order)) {
        $skippedCount++;
        echo "Skip record: invalid order ID\n";
        continue;
    }

    $orderId = $order['id'];

    if (!isPaidOrder($order)) {
        $skippedCount++;
        $status = $order['status'] ?? '';
        $message = statusMessage($status);
        echo "Skip order $orderId: $message\n";
        continue;
    }

    if (!hasPositiveTotal($order)) {
        $skippedCount++;
        echo "Skip order $orderId: total must be positive\n";
        continue;
    }

    $shippedCount++;
    echo "Ship order $orderId\n";
}

echo "Shipped: $shippedCount\n";
echo "Skipped: $skippedCount\n";

Output:

Ship order 701
Skip order 702: Waiting for payment
Skip order 703: total must be positive
Skip record: invalid order ID
Skip order 705: Unknown status
Shipped: 1
Skipped: 4

The guards are ordered deliberately. The script validates the ID before reading it into $orderId. It checks payment before checking the total because an unpaid order already has a clear reason not to ship. Each rejected path increments one counter, prints one reason, and uses continue to prevent later checks from running for that order.

Trace One Record At A Time

Do not try to understand the entire loop at once. Trace each record through the same questions:

Order Valid ID? Paid? Positive total? Result
701 yes yes yes ship
702 yes no not checked skip: waiting for payment
703 yes yes no skip: invalid total
missing ID no not checked not checked skip: invalid ID
705 yes no not checked skip: unknown status

"Not checked" is meaningful. Once a guard uses continue, execution moves to the next order. A later condition cannot affect the current record.

This trace also proves the counters. One record ships and four records skip, so 1 + 4 equals the five input records. If those totals do not balance, a path probably increments twice or not at all.

Understand Which Scope Owns Each Value

The workflow uses several state lifetimes:

  • $orders, $shippedCount, and $skippedCount belong to the top-level script;
  • $order is the current loop value in that same top-level scope;
  • $orderId, $status, and $message are temporary names used during an iteration;
  • each helper function has its own local $order parameter;
  • no helper reads or changes the top-level counters.

The counters stay in the loop because the loop owns the processing summary. The boolean functions remain predictable because their answer depends only on the order argument.

Do not move $skippedCount into a hidden global merely so a helper can increment it. That would mix answering a question with changing workflow state. Let the helper return an answer and let the caller decide what that answer means.

Avoid Clever Combined Conditions Too Early

This condition is compact:

PHP example
foreach ($orders as $order) {
    if (!isPaidOrder($order) || !hasPositiveTotal($order)) {
        continue;
    }

    echo "Process order {$order['id']}\n";
}

It is suitable when every failed condition has the same outcome. It is less useful when the script must print a specific rejection reason, count different categories, or validate one field before safely reading another.

Separate guards are longer but preserve information about the failed rule. Prefer clarity of behavior over minimum line count.

Check Boundary Values Deliberately

A positive-total rule has three important nearby values:

PHP example
var_dump(hasPositiveTotal(['total_cents' => -1]));
var_dump(hasPositiveTotal(['total_cents' => 0]));
var_dump(hasPositiveTotal(['total_cents' => 1]));

Expected output:

bool(false)
bool(false)
bool(true)

These checks prove that > 0 matches the stated policy. Testing only 2500 would not distinguish > 0 from >= 0.

Status checks also need exact alternatives: paid should pass, while pending, PAID, an empty string, and a missing key should not. Strict comparison makes that policy visible.

Normal Rejection Is Still Control Flow

In this workflow, pending payment, a missing ID, and a non-positive total are represented as normal rejected paths. The script can describe each result and continue processing later records.

The next lesson introduces PHP errors and exceptions for failures that cannot continue normally. Do not add exception syntax to this milestone before learning how throwing, catching, and exception boundaries work. Here, explicit return values and branches are enough to model every expected outcome.

Review Checklist

Before considering a workflow complete, check:

  • every array key is read only after a fallback or validity check;
  • exact status values use strict comparison;
  • each helper function answers one named question;
  • helpers receive required data through parameters;
  • no helper mutates hidden global counters;
  • each order reaches exactly one final outcome;
  • every continue intentionally skips all later statements in the loop;
  • boundary values around zero are tested;
  • unknown statuses have a deliberate fallback;
  • summary counts reconcile with the number of input records.

Common Integration Mistakes

Symptom Likely cause
Missing-key warning Array key read before fallback or validation
Invalid order ID appears in output ID was used before its guard passed
One order prints two outcomes Missing continue after a rejected path
Rejection reason is too vague Several rules were collapsed into one condition
Counter is too high More than one path increments it for one record
Counter is too low A branch exits without recording its outcome
Helper result changes unexpectedly Function depends on hidden global state
Unknown status produces no message Missing default policy
Zero-value test passes incorrectly Used >= 0 instead of > 0

What This Milestone Proves

You are ready to continue when you can define the policy before coding, extract boolean rules into small functions, choose deliberate missing-key fallbacks, map exact values with match, order loop guards safely, trace which conditions are skipped, keep counters in the scope that owns them, test boundary values, and reconcile every input record with one output result.

Being able to explain why each record follows its path matters more than memorising the final output. The explanation is the real milestone. The exercises isolate three parts of this workflow: validating orders, predicting a missing-key fallback, and building an exhaustive status router.

Practice

Task: Validate Paid Orders

Task

Build a script that classifies every order as shipped or skipped.

Create these functions:

function hasValidOrderId(array $order): bool
function isPaidOrder(array $order): bool
function hasPositiveTotal(array $order): bool

Use deliberate fallbacks for missing keys: 0 for numeric fields and '' for status.

PHP example
<?php

$orders = [
    ['id' => 801, 'status' => 'paid', 'total_cents' => 3200],
    ['id' => 802, 'status' => 'failed', 'total_cents' => 3200],
    ['id' => 803, 'status' => 'paid', 'total_cents' => 0],
    ['status' => 'paid', 'total_cents' => 900],
    ['id' => 805, 'status' => 'paid', 'total_cents' => 1500],
];

Process the checks in this order: valid ID, paid status, positive total. Use continue after each rejection. Count shipped and skipped orders, then print both totals.

Expected output:

Ship order 801
Skip order 802: not paid
Skip order 803: invalid total
Skip record: invalid ID
Ship order 805
Shipped: 2
Skipped: 3

Hints

  • Validate the ID before assigning $order['id'] to a local variable.
  • Increment exactly one counter for each order.
  • Keep counters in the top-level workflow rather than changing them inside helper functions.
Show solution

Solution

PHP example
<?php

function hasValidOrderId(array $order): bool
{
    return ($order['id'] ?? 0) > 0;
}

function isPaidOrder(array $order): bool
{
    return ($order['status'] ?? '') === 'paid';
}

function hasPositiveTotal(array $order): bool
{
    return ($order['total_cents'] ?? 0) > 0;
}

$orders = [
    ['id' => 801, 'status' => 'paid', 'total_cents' => 3200],
    ['id' => 802, 'status' => 'failed', 'total_cents' => 3200],
    ['id' => 803, 'status' => 'paid', 'total_cents' => 0],
    ['status' => 'paid', 'total_cents' => 900],
    ['id' => 805, 'status' => 'paid', 'total_cents' => 1500],
];

$shippedCount = 0;
$skippedCount = 0;

foreach ($orders as $order) {
    if (!hasValidOrderId($order)) {
        $skippedCount++;
        echo "Skip record: invalid ID\n";
        continue;
    }

    $orderId = $order['id'];

    if (!isPaidOrder($order)) {
        $skippedCount++;
        echo "Skip order $orderId: not paid\n";
        continue;
    }

    if (!hasPositiveTotal($order)) {
        $skippedCount++;
        echo "Skip order $orderId: invalid total\n";
        continue;
    }

    $shippedCount++;
    echo "Ship order $orderId\n";
}

echo "Shipped: $shippedCount\n";
echo "Skipped: $skippedCount\n";

Explanation

The helper functions answer three independent questions and do not mutate workflow state. Each rejected path records one skipped order and stops processing that record with continue.

The ID guard runs first, so the script never reads $order['id'] from the record where it is missing. The two shipped results plus the three skipped results reconcile with all five input records.

Task: Predict Missing Status Paths

Task

Before running this code, predict every output line:

PHP example
<?php

function orderStatus(array $order): string
{
    return $order['status'] ?? 'missing';
}

function isPaidOrder(array $order): bool
{
    return orderStatus($order) === 'paid';
}

$orders = [
    ['id' => 901, 'status' => 'paid'],
    ['id' => 902],
    ['id' => 903, 'status' => null],
    ['id' => 904, 'status' => 'pending'],
];

foreach ($orders as $order) {
    $status = orderStatus($order);
    $result = isPaidOrder($order) ? 'paid' : 'not paid';

    echo "{$order['id']}: $status, $result\n";
}

Write down the four lines in order and explain why orders 902 and 903 receive the same status fallback. Then run the unchanged code.

Hints

  • ?? uses its fallback when the key is missing or its value is null.
  • Only the exact string paid satisfies the strict comparison.
  • The fallback string describes missing data; it does not make the order paid.
Show solution

Solution

PHP example
<?php

function orderStatus(array $order): string
{
    return $order['status'] ?? 'missing';
}

function isPaidOrder(array $order): bool
{
    return orderStatus($order) === 'paid';
}

$orders = [
    ['id' => 901, 'status' => 'paid'],
    ['id' => 902],
    ['id' => 903, 'status' => null],
    ['id' => 904, 'status' => 'pending'],
];

foreach ($orders as $order) {
    $status = orderStatus($order);
    $result = isPaidOrder($order) ? 'paid' : 'not paid';

    echo "{$order['id']}: $status, $result\n";
}

// Prints:
// 901: paid, paid
// 902: missing, not paid
// 903: missing, not paid
// 904: pending, not paid

Explanation

Order 901 contains the exact string paid, so both functions report it as paid. Order 904 preserves its pending value, which fails the strict comparison.

For order 902, the key is absent. For order 903, the key exists but contains null. The null-coalescing operator uses missing in both situations. That policy avoids an undefined-key warning and gives both incomplete states an explicit label.

Task: Build Status Router

Task

Write a function with this signature:

function statusMessage(string $status): string

Use match to implement these exact mappings:

  • paid -> Ready to ship
  • pending -> Waiting for payment
  • failed -> Payment failed
  • cancelled -> Order cancelled
  • every other value -> Unknown status

Loop over this array and print one line for every value:

PHP example
$statuses = ['paid', 'pending', 'failed', 'cancelled', 'refunded'];

Format each line as <status>: <message>. The final line must prove the fallback is reachable.

Hints

  • Return the match expression directly.
  • Include a default arm.
  • Keep output in the caller so the function only performs the mapping.
Show solution

Solution

PHP example
<?php

function statusMessage(string $status): string
{
    return match ($status) {
        'paid' => 'Ready to ship',
        'pending' => 'Waiting for payment',
        'failed' => 'Payment failed',
        'cancelled' => 'Order cancelled',
        default => 'Unknown status',
    };
}

$statuses = ['paid', 'pending', 'failed', 'cancelled', 'refunded'];

foreach ($statuses as $status) {
    echo "$status: " . statusMessage($status) . "\n";
}

// Prints:
// paid: Ready to ship
// pending: Waiting for payment
// failed: Payment failed
// cancelled: Order cancelled
// refunded: Unknown status

Explanation

Each exact status selects one arm and returns one string. match uses strict comparisons and does not fall through between arms.

The refunded input reaches default, proving that unexpected values receive a deliberate answer. Output remains outside statusMessage(), so the mapper can be reused anywhere a string result is needed.