Capstone: Maintainable PHP App
The capstone consolidates the routed product catalog into one repository another developer can install, migrate, run, test, review, and release. Reuse the router, renderer, MVC slice, PDO repository, authentication, CLI report, and read-only JSON representation already built. Do not restart from a folder diagram.
Freeze A Finishable Contract
The required release has these observable behaviors:
public product list and detail pages
admin login and CSRF-protected logout
protected product create, edit, and delete
SQLite migrations and prepared PDO repositories
server-side validation and escaped templates
CLI product report with documented streams and exits
read-only JSON list and detail with stable errors
repeatable setup, automated checks, health check, and release notes
Private document uploads, mail, queues, and third-party APIs are stretch work. Add one only after the required release passes from a clean checkout. “Done” means rejected paths work too: unknown IDs, invalid fields, anonymous writes, bad CSRF, wrong credentials, malformed pagination, repository failures, and invalid CLI usage.
Use A Repository Shape With Owners
bin/migrate.php
bin/products-report.php
bin/verify-environment.php
config/templates.php
migrations/*.sql
public/index.php
src/Config/ApplicationConfig.php
src/Controller/
src/Http/
src/Repository/
src/View/
templates/
tests/Integration/
tests/Unit/
var/app.sqlite
var/log/
.env.example
composer.json
composer.lock
README.md
Only public/ is a web root. var/ is writable runtime state and ignored except for placeholder files needed to retain directories. composer.lock is committed for an application so every checkout installs the reviewed dependency versions. SQL belongs in repositories and migrations; HTML belongs in templates; concrete construction belongs in the front-controller or CLI composition root.
Fail Fast On Configuration
Do not let missing environment values become empty secrets or surprising production defaults. Put a typed boundary in src/Config/ApplicationConfig.php:
<?php
declare(strict_types=1);
final readonly class ApplicationConfig
{
public function __construct(
public string $environment,
public string $databasePath,
public string $appSecret,
public bool $secureCookies,
) {}
public static function fromEnvironment(): self
{
$environment = self::required('APP_ENV');
if (!in_array($environment, ['development', 'test', 'production'], true)) {
throw new RuntimeException('APP_ENV is invalid.');
}
$secret = self::required('APP_SECRET');
if (strlen($secret) < 32) {
throw new RuntimeException('APP_SECRET must contain at least 32 bytes.');
}
$secure = filter_var(
self::required('SESSION_SECURE_COOKIE'),
FILTER_VALIDATE_BOOL,
FILTER_NULL_ON_FAILURE,
);
if ($secure === null) {
throw new RuntimeException('SESSION_SECURE_COOKIE must be true or false.');
}
if ($environment === 'production' && !$secure) {
throw new RuntimeException('Production session cookies must be secure.');
}
return new self(
$environment,
self::required('DATABASE_PATH'),
$secret,
$secure,
);
}
private static function required(string $name): string
{
$value = getenv($name);
if (!is_string($value) || $value === '') {
throw new RuntimeException($name . ' is required.');
}
return $value;
}
}
.env.example contains names and non-secret development placeholders, never real credentials. Document how local values are loaded; PHP does not read .env automatically. A deployment platform can inject variables directly, or Composer can install a reviewed environment-file library for local development.
The bootstrap enables E_ALL, sets a deliberate timezone, disables display_errors outside development, and logs unexpected failures without request bodies, passwords, cookies, tokens, or stack traces in public responses.
Track Applied Migrations
A migration command should know what ran and detect edits to already-applied files. Use lexically ordered, immutable SQL files such as 001_create_products.sql:
<?php
declare(strict_types=1);
final class MigrationRunner
{
public function __construct(
private PDO $pdo,
private string $directory,
) {}
public function migrate(): int
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
)'
);
$files = glob($this->directory . '/*.sql');
if ($files === false) {
throw new RuntimeException('Migration directory could not be read.');
}
sort($files, SORT_STRING);
$appliedCount = 0;
foreach ($files as $file) {
$version = basename($file);
$sql = file_get_contents($file);
if ($sql === false) {
throw new RuntimeException('Migration could not be read: ' . $version);
}
$checksum = hash('sha256', $sql);
$lookup = $this->pdo->prepare(
'SELECT checksum FROM schema_migrations WHERE version = :version'
);
$lookup->execute(['version' => $version]);
$appliedChecksum = $lookup->fetchColumn();
if ($appliedChecksum !== false) {
if (!hash_equals((string) $appliedChecksum, $checksum)) {
throw new RuntimeException('Applied migration changed: ' . $version);
}
continue;
}
$this->pdo->beginTransaction();
try {
$this->pdo->exec($sql);
$record = $this->pdo->prepare(
'INSERT INTO schema_migrations (version, checksum, applied_at)
VALUES (:version, :checksum, :applied_at)'
);
$record->execute([
'version' => $version,
'checksum' => $checksum,
'applied_at' => (new DateTimeImmutable())->format(DATE_ATOM),
]);
$this->pdo->commit();
$appliedCount++;
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
}
return $appliedCount;
}
}
bin/migrate.php loads configuration, opens PDO with exception mode, invokes this runner, and reports only the applied count. Run one migration process per environment. Never rewrite an applied migration; add a forward migration. Before a risky release, back up data and verify restoration. Application rollback is safe only when schema changes remain backward compatible; destructive changes require an explicit data recovery plan.
Preserve Security Invariants
The consolidated app must keep these invariants visible:
| Boundary | Required invariant |
|---|---|
| Session | strict mode; Secure, HttpOnly, and SameSite; regenerate after login |
| Password | password_hash, unconditional password_verify, rehash on success, throttled failures |
| Browser write | method, current authorization, CSRF, validation, then persistence |
| SQL | prepared values, bounded pagination, exceptions converted only at the outer boundary |
| HTML | escape at the final context; only renderer-produced fragments become SafeHtml |
| JSON | bounded read, typed object validation, stable errors, no exception details |
| CLI | validated $argv, separate output/error streams, documented non-zero exits |
| Configuration | required values validated at startup; secrets absent from Git and logs |
Use database transactions when a use case must commit several writes as one invariant, such as product creation plus an audit record. Do not wrap unrelated read requests in transactions or claim filesystem and database writes are one atomic transaction.
Make Verification Reproducible
Define stable Composer scripts rather than requiring reviewers to remember commands:
{
"scripts": {
"check": [
"@php -l public/index.php",
"@test",
"@analyse"
],
"test": "php -d zend.assertions=1 -d assert.exception=1 vendor/bin/phpunit",
"analyse": "vendor/bin/phpstan analyse src tests"
}
}
Add formatting or coding-standard checks when configured, and run the same commands in CI. Pin tool configuration in the repository. A passing linter does not replace behavior tests.
Minimum evidence:
| Risk | Required evidence |
|---|---|
| Validation and price parsing | unit tests for accepted, rejected, boundary, and array-shaped values |
| PDO and migrations | SQLite integration tests, second migration run is a no-op, changed checksum fails |
| Authentication and authorization | HTTP tests for login, session rotation, role change, throttle, and rejected writes |
| CSRF | missing and incorrect tokens cause no write |
| Templates | malicious text, invalid UTF-8, and renderer buffer cleanup |
| JSON | list, found, missing, invalid pagination, stable content type and scalar types |
| CLI | line/CSV output, escaped CSV fields, standard error, and exit codes |
| Wiring | one HTTP smoke test through the real bootstrap and route table |
Tests use a temporary database and isolated session/storage directories. They never overwrite a developer or production database.
Write Setup And Release Instructions
From a clean checkout, the README gives exact commands for requirements, composer install, environment setup, writable directories, migration, local server, checks, CLI report, and sample login seeding. It includes the request path through router/controller/repository/renderer and identifies the outer exception boundary.
A release note records:
- required configuration and dependency changes;
- backup and migration command;
- application deployment order;
/healthor equivalent process/database readiness check;- one public read and one protected-write smoke test;
- log inspection without exposing secrets;
- rollback conditions, application revision, and data-restoration owner.
A health endpoint should reveal no versions, paths, DSNs, or exception messages. Liveness can report that PHP serves requests; readiness may run a bounded database query when the deployment platform needs it.
Final Review
Prove the app from a clean temporary checkout using only committed files and README instructions. Review tracked and generated files for secrets, database files, logs, uploads, caches, and environment overrides. Explain one deliberate tradeoff, its risk, and what evidence would justify changing it.
Practice
Practice: Finish The Maintainable PHP Capstone
Consolidate the routed catalog increments into one repository and prove the required release from a clean checkout.
Required Application
- Public product list and positive-ID detail routes.
- Administrator login, CSRF-protected logout, and current-role authorization.
- Protected create, edit, and delete with validation and POST/redirect/GET.
- SQLite PDO repository and immutable tracked migrations.
- Allow-listed templates with explicit view data and contextual escaping.
- CLI line/CSV report with documented streams and exit codes.
- Read-only JSON list/detail with bounded pagination and stable errors.
- Generic outer exception handling and environment-appropriate error display.
Delivery Requirements
- Validate required configuration at startup and reject insecure production cookies.
- Keep secrets, databases, logs, uploads, caches, and local environment files out of Git.
- Commit
composer.lockand stable Composer check scripts. - Make migration reruns no-ops and reject changed applied migrations.
- Use temporary databases and storage in tests.
- Test unit, repository, renderer, controller, router, HTTP wiring, CLI, and JSON boundaries.
- Document exact clean-checkout setup, migration, run, test, and report commands.
- Document backup, health, smoke, log, rollback, and data-restoration steps.
- Keep stretch features outside the required release until all evidence passes.
Produce the working repository, .env.example, README, architecture note, completed evidence table with commands/test names, release note, and one tradeoff record stating decision, risk, and reconsideration trigger.
Show solution
The capstone solution is the integrated repository, not one oversized PHP listing. It is complete only when another developer can reproduce the following evidence without undocumented setup.
Clean Checkout Evidence
composer install
cp .env.example .env
mkdir -p var/log
php bin/verify-environment.php
php bin/migrate.php
composer check
php -S 127.0.0.1:8000 -t public public/index.php
verify-environment.php checks configuration names, required PHP extensions, database-parent and log-directory writability, and whether production cookies are secure. It reports variable names and corrective actions, never secret values or the full DSN.
A minimal verification entry point can use the typed configuration boundary:
<?php
declare(strict_types=1);
// no-execute: requires project autoloading and deployment environment values.
require dirname(__DIR__) . '/vendor/autoload.php';
$config = ApplicationConfig::fromEnvironment();
$requiredExtensions = ['pdo', 'pdo_sqlite', 'fileinfo'];
$missing = array_values(array_filter(
$requiredExtensions,
static fn (string $extension): bool => !extension_loaded($extension),
));
if ($missing !== []) {
fwrite(STDERR, 'Missing PHP extensions: ' . implode(', ', $missing) . PHP_EOL);
exit(1);
}
$databaseDirectory = dirname($config->databasePath);
if (!is_dir($databaseDirectory) || !is_writable($databaseDirectory)) {
fwrite(STDERR, "Database directory is not writable.\n");
exit(1);
}
echo "Environment checks passed.\n";
Automated Evidence
Record exact test identifiers or CI links for:
ProductValidatorTest accepted, boundary, array-shaped, and rejected values
MigrationRunnerTest first run, no-op rerun, rollback, changed checksum
ProductRepositoryTest CRUD, mapping, missing row, bounded pagination
TemplateRendererTest escaping, invalid UTF-8, missing template, buffer cleanup
ProductControllerTest 401, 403, CSRF, 422, normalized insert, 303
AuthenticationFlowTest dummy hash path, rehash, throttle, role refresh, logout
RouterTest captures, HEAD, 404, 405 Allow, handler failure
JsonProductApiTest typed list/detail, 404, pagination, generic 500
ProductReportCommandTest lines, CSV quoting, stderr, success and usage exits
ApplicationSmokeTest real bootstrap, route table, temporary SQLite database
Run the migration suite against SQLite :memory: where possible and a temporary file where separate connections or locking behavior matters. Assert that failed CSRF, authorization, and validation paths leave the database unchanged.
Manual HTTP And CLI Evidence
curl -i http://127.0.0.1:8000/products
curl -I http://127.0.0.1:8000/products
curl -i http://127.0.0.1:8000/api/products/999999
php bin/products-report.php published csv
php bin/products-report.php archived
echo $?
Use a cookie jar for login and protected-write checks. Record status, Location/Allow/content-type headers, response shape, and database effect. Insert <script>alert(1)</script> as a product name and verify it renders as text in HTML while remaining a JSON string in the API.
Architecture And Release Evidence
The architecture note names the concrete flow:
public/index.php constructs infrastructure and captures Request.
Router selects an application-owned handler and returns Response.
ProductController coordinates authorization, CSRF, validation, persistence, and rendering.
ProductRepository owns prepared PDO statements and mapping.
TemplateRenderer allow-lists files, isolates view data, and restores output buffers.
The front controller sends one response and converts unexpected failures to generic 500 output.
The release note names the backup artifact and restoration owner, migration command, deployment revision, readiness check, smoke requests, log query, rollback trigger, and previous application revision. For a migration that removes or rewrites data, application rollback alone is explicitly insufficient.
A valid tradeoff record is concrete: “SQLite keeps deployment simple for a single-instance portfolio app; concurrent write capacity is limited. Reconsider when measured lock waits or multi-instance deployment becomes a requirement.”