PHP / Advanced: reliable models and runtime evidence / Lesson 17
Maintainable HTTP and JSON API boundaries
Compose parsing, authorization, domain work, persistence, and response construction without a framework. Use explicit adapters and error contracts instead of coupling business rules to superglobals.
On this page
Lesson goals
- Validate JSON syntax and domain shape separately.
- Separate adapters, application services, and domain rules.
- Define status, idempotency, and evidence limitations.
Choose a few honest boundaries
A small PHP application can separate request decoding, access policy, application coordination, domain rules, persistence adapters, and response emission without adopting Laravel or another framework. The benefit is visible ownership, not the number of directories. Domain calculations should accept validated values and return meaningful results without reading $_POST or sending headers. A coordinating service owns transactions and calls adapters; the HTTP boundary maps known outcomes to status and safe representations. Avoid a global container lookup inside every function, which conceals dependencies and makes focused tests require unrelated application configuration.
JSON syntax is not a domain schema
json_decode with JSON_THROW_ON_ERROR makes malformed JSON explicit, but a successful decode may still produce null, a list, or a record with wrong field types. Validate the exact shape before constructing domain values. Distinguish missing, nullable, and forbidden fields; decide whether unknown fields are rejected or ignored by policy. The example requires an object-like associative record with a nonempty title and nonnegative integer seats. It decodes only a literal synthetic string and performs no request or database access. A real boundary also needs body-size, content-type, and permission checks that this local decoder cannot establish.
<?php
declare(strict_types=1);
function decodeCourse(string $json): array {
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data) || !isset($data["title"], $data["seats"]) ||
!is_string($data["title"]) || trim($data["title"]) === "" ||
!is_int($data["seats"]) || $data["seats"] < 0) {
throw new InvalidArgumentException("invalid course shape");
}
return ["title" => trim($data["title"]), "seats" => $data["seats"]];
}
$course = decodeCourse('{"title":" PHP ","seats":2}');
echo $course["title"], " ", $course["seats"], PHP_EOL;
PHP 2
The returned record contains only the selected validated fields. A valid JSON string alone would not guarantee those fields.
Status and error shape are caller-facing contracts
Define which outcomes represent malformed input, invalid domain values, unauthenticated access, forbidden access, missing resources, conflict, and unexpected failure. Some applications intentionally conceal resource existence; document that policy rather than assuming one universal status mapping. An error payload should contain stable safe categories and useful correction guidance, not raw PDO or filesystem details. Keep success and failure distinct even when both serialize to JSON. A read-only response must not mutate state accidentally, and a failed write must not leave partial effects merely because the error body looks correct.
Test response decisions without emitting HTTP
The local adapter model maps a synthetic lookup to success or absence. It exposes only the intended public fields and uses a stable error code for missing records. No real route, session, login, or configured repository is called. This makes the mapping easy to test, while an isolated HTTP integration check remains necessary for actual headers, routing, and middleware behavior. If privacy policy requires hiding existence, the authorization layer and lookup mapping need coordinated rules. Do not add a data field to the public output just because the storage record contains it.
<?php
declare(strict_types=1);
function readResponse(array $courses, int $id): array {
if (!array_key_exists($id, $courses)) {
return ["status" => 404, "body" => ["error" => "course_missing"]];
}
return ["status" => 200, "body" => ["id" => $id, "title" => $courses[$id]["title"]]];
}
$courses = [1 => ["title" => "PHP", "internalNote" => "synthetic private field"]];
echo json_encode(readResponse($courses, 1), JSON_THROW_ON_ERROR), PHP_EOL;
echo json_encode(readResponse($courses, 2), JSON_THROW_ON_ERROR), PHP_EOL;
{"status":200,"body":{"id":1,"title":"PHP"}}
{"status":404,"body":{"error":"course_missing"}}
internalNote is not exposed. The status integers model decisions and are not sent as actual HTTP responses.
Maintain compatibility and evidence as the API changes
Version the public contract deliberately when field meaning, required inputs, or error categories change. For retries of writes, define an operation identity and server-side idempotency policy where appropriate; a lost response does not prove the action never committed. Keep transaction and external-effect ownership explicit. Test decoders, domain rules, adapter failures, and one composed user workflow with synthetic data. A fake repository supports focused evidence but does not validate the real driver or server contract. Publish supported runtime and extension assumptions and record remaining integration gaps so maintainability is an inspectable property rather than a framework label.
Common errors and fixes
Decoded JSON is accepted as a valid domain record
- Why it happens
- Syntax validation is confused with shape and semantic validation.
- How to fix it
- Validate required fields, types, nullability, and limits.
- Prevent it next time
- Test null, lists, wrong types, and unknown fields explicitly.
Private storage fields leak into an API
- Why it happens
- Whole records are serialized without a public schema.
- How to fix it
- Select an allowlisted response representation.
- Prevent it next time
- Assert forbidden fields are absent in contract tests.
A lost write response is retried blindly
- Why it happens
- Transport uncertainty is mistaken for no committed effect.
- How to fix it
- Reconcile operation identity and use an agreed idempotency policy.
- Prevent it next time
- Test ambiguous completion and duplicate requests safely.
Check your understanding
1. Why should a domain calculator not read $_POST directly?
Show worked solution
It would mix transport parsing with domain calculation and tie focused tests to a web host. Decode and validate in an adapter, then pass explicit domain values. This keeps the same rule usable in CLI checks and HTTP while making errors and authorization ownership visible.
2. A stored record has a private note. How should a JSON read endpoint handle it?
Show worked solution
Build the response from an explicit public-field list and test that the private note is absent. Do not serialize the storage record wholesale. Perform permission checks before disclosure, and document the policy for missing versus unauthorized resources.
Key takeaways
- Separate transport decoding, domain rules, persistence, and response emission.
- A maintained API contract includes failures, privacy, retries, and evidence limits.