Practical Capstone Projects

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 example
<?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 example
<?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:

  1. required configuration and dependency changes;
  2. backup and migration command;
  3. application deployment order;
  4. /health or equivalent process/database readiness check;
  5. one public read and one protected-write smoke test;
  6. log inspection without exposing secrets;
  7. 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.lock and 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 example
<?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.”