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:
- a missing or non-positive ID means the record is invalid;
- only the exact status
paidcan ship; - a missing, zero, or negative total cannot ship;
- every order produces one result line;
- 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
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
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
$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$skippedCountbelong to the top-level script;$orderis the current loop value in that same top-level scope;$orderId,$status, and$messageare temporary names used during an iteration;- each helper function has its own local
$orderparameter; - 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:
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:
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
continueintentionally 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
$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
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
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 isnull.- Only the exact string
paidsatisfies the strict comparison. - The fallback string describes missing data; it does not make the order paid.
Show solution
Solution
<?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 shippending->Waiting for paymentfailed->Payment failedcancelled->Order cancelled- every other value ->
Unknown status
Loop over this array and print one line for every value:
$statuses = ['paid', 'pending', 'failed', 'cancelled', 'refunded'];
Format each line as <status>: <message>. The final line must prove the fallback is reachable.
Hints
- Return the
matchexpression directly. - Include a
defaultarm. - Keep output in the caller so the function only performs the mapping.
Show solution
Solution
<?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.