Capstone Project: Designing, Building, Securing, and Operating TaskForge — a Production-Grade PHP SaaS Platform

Synthesize everything covered across this course into a single, realistic project: design and build TaskForge, a multi-tenant task and project management SaaS, using modern PHP language features, PSR-conformant Composer architecture, a hexagonal application design, PDO-backed persistence with migrations and transactions, a versioned REST API, layered authentication and authorization, Redis caching and a background job queue, a PHPStan-gated automated test suite, a multi-stage Docker build, a CI/CD pipeline with health-checked deployment, and structured observability — and to be able to explain, in an interview or a design review, why each decision was made.

Every lesson up to this point has taught one slice of professional PHP development in isolation: how readonly properties and enums tighten a domain model, how PSR-4 autoloading and Composer wire a codebase together, how a hexagonal architecture keeps business rules independent of frameworks, how PDO and migrations manage schema safely, how a REST API should version, paginate, and report errors, how sessions and tokens authenticate a user while role checks authorize what they can do, how Redis and a queue keep an API fast under load, how PHPStan and a testing pyramid catch regressions before users do, how a multi-stage Dockerfile and a CI/CD pipeline turn a git push into a running deployment, and how structured logs, correlation IDs, and health endpoints let you see what a running system is actually doing. This capstone is where those slices stop being separate lessons and become one coherent system. We are going to build TaskForge: a multi-tenant SaaS where organizations sign up, invite teammates with different roles, create projects, and manage tasks through a web-facing REST API. It is deliberately unglamorous — task management is a domain everyone already understands — so that all of the engineering weight falls on the architecture, not on explaining the business. Nothing here is new material; every code excerpt below is a direct, named callback to a specific earlier lesson, deployed together, under the constraints of a single running application. By the end, you will have a mental blueprint you can reuse for any real-world PHP system you're asked to build professionally.

Phase 1 — Project Shape: Composer, PSR-4, and the Directory Skeleton

Every production PHP application starts the same way a well-organized house starts: with a floor plan, not a pile of bricks. Before a single domain rule is written, TaskForge is set up as a Composer package with a composer.json declaring PSR-4 autoloading, exactly as covered in the Composer and Autoloading lesson and the PSR-4 Namespace Mapping lesson. The namespace root TaskForge\ maps to src/, and test doubles map separately under TaskForge\Tests\ to tests/. This is not bureaucracy — PSR-4 autoloading is what lets a hexagonal architecture exist at all in PHP: because classes are located by namespace-to-path convention rather than manual require chains, we can freely reorganize src/Domain, src/Application, and src/Infrastructure into separate layers without touching a single include statement anywhere else in the codebase.

The directory skeleton mirrors the layers from the Hexagonal Architecture lesson directly: src/Domain holds entities, value objects, and repository *interfaces* with zero framework dependencies; src/Application holds use-case services that orchestrate domain objects; src/Infrastructure holds concrete implementations — PDO repositories, Redis cache adapters, the queue producer — that satisfy those interfaces; and src/Http holds controllers and middleware that translate HTTP requests into application-service calls and application results back into HTTP responses. This is the same 'dependency rule' taught in that lesson: dependencies point inward, so Domain never imports from Infrastructure, only the other way around.

Every third-party dependency is chosen deliberately around PSR interfaces rather than concrete implementations, following the PSR-7/PSR-15/PSR-18 Interoperability lesson: TaskForge's HTTP layer is written against Psr\Http\Message\ServerRequestInterface and ResponseInterface, and its middleware pipeline against Psr\Http\Server\MiddlewareInterface, so the actual PSR-7 implementation (e.g. nyholm/psr7) and the actual middleware dispatcher (e.g. a small PSR-15 runner) are swappable without touching a single controller. This is the same principle as the repository interfaces below: code against the abstraction the ecosystem has standardized, not the library that happens to implement it today.

• composer.json declares PSR-4 autoloading: TaskForge\\ → src/, TaskForge\\Tests\\ → tests/ (Composer and Autoloading; PSR-4 Namespace Mapping). • src/Domain, src/Application, src/Infrastructure, src/Http mirror the four layers from the Hexagonal Architecture lesson. • The dependency rule: Domain never depends on Infrastructure — Infrastructure implements interfaces Domain declares. • HTTP and middleware code targets PSR-7/PSR-15 interfaces, not a specific library, per the PSR Interoperability lesson.

Phase 2 — The Domain Layer: Entities, Value Objects, Enums, and Readonly State

The domain layer is where TaskForge's business rules live, and it's also where modern PHP's type system earns its keep. A Task is modeled as a class with readonly properties for its identity and creation timestamp — following the Readonly Properties and Immutability lesson — because a task's ID and creation time must never silently change after construction, and making that structurally impossible is far stronger than a code-review convention. Its status is a backed enum TaskStatus, not a string or an integer constant, exactly as taught in the Enums for Domain Modeling lesson: the compiler now rejects an invalid status like 'donee' at the type level, and a match expression over the enum's cases (with no default arm) means adding a new status later causes every match site in the codebase to fail static analysis until it's handled — a deliberate, desirable form of friction.

State transitions are modeled as methods on the entity itself rather than as external code that mutates public fields, which keeps invariants enforceable in one place. Task::complete() internally uses match on the current status to decide whether the transition is legal, throwing a domain-specific exception (InvalidTaskTransitionException) rather than a generic one, echoing the Custom Exception Hierarchies lesson's guidance that domain errors should be distinguishable from infrastructure errors by type, not by string-matching a message.

Value objects round out the domain: an Email value object validates format and lowercases on construction, a TaskId wraps a UUID string so it can never be confused with a raw string parameter in a method signature, and constructors throughout the domain use named arguments (per the Named Arguments and Constructor Promotion lesson) so that call sites like Task::create(title: $title, projectId: $projectId, assignee: $assignee) stay readable even as the parameter list grows, and so that reordering parameters during refactors doesn't silently swap two same-typed arguments at a call site.

• Readonly properties lock identity/creation fields at construction (Readonly Properties and Immutability lesson). • Backed enums model finite domain states; exhaustive match with no default catches unhandled cases at analysis time (Enums for Domain Modeling lesson). • State transitions live as methods on the entity, throwing typed domain exceptions (Custom Exception Hierarchies lesson). • Value objects (Email, TaskId) make illegal values unrepresentable; named arguments keep multi-parameter constructors self-documenting (Named Arguments and Constructor Promotion lesson).

Phase 3 — Ports and Adapters: Repository Interfaces, PDO, Migrations, and Transactions

Following the Repository Pattern lesson, the domain declares a TaskRepositoryInterface with methods like findById, save, and findByProjectPaginated, and knows nothing about SQL, PDO, or Redis. The concrete PdoTaskRepository in the Infrastructure layer implements that interface using prepared statements exclusively — never string-interpolated SQL — which is both the SQL Injection Prevention lesson's core rule and simply correct PDO usage. Every write path that touches more than one table (creating a task and writing an audit-log row, for example) is wrapped in an explicit PDO transaction with beginTransaction/commit/rollBack inside a try/catch, as covered in the Transactions and Data Integrity lesson, so a mid-operation failure can never leave the database in a half-written state.

Schema changes ship as versioned, timestamped migration files rather than hand-run ALTER statements, following the Database Migrations lesson — each migration has an up() and a down(), migrations are applied in a fixed order tracked in a migrations table, and CI runs every migration against a throwaway database as part of the pipeline (Phase 8) to guarantee they actually apply cleanly from zero. Hot query paths get explicit indexes decided from real access patterns, not guesswork: tasks is indexed on (project_id, status) because the single most frequent query in the whole application is 'list open tasks for this project, paginated', and a composite index matching that exact filter-plus-sort shape is what turns that query from a sequential scan into an index range scan as data grows — precisely the diagnostic process taught in the Query Performance and Indexing lesson.

Later schema changes (for example, splitting a single assignee_id column into a many-to-many task_assignees table to support multiple assignees per task) follow the expand/contract pattern from the Zero-Downtime Schema Changes lesson: first expand (add the new table, dual-write to both the old column and new table), deploy, backfill, switch reads to the new table, then contract (stop writing the old column, drop it in a later migration) — never a single migration that renames or drops a column a running previous-version deployment still depends on.

• TaskRepositoryInterface (Domain) / PdoTaskRepository (Infrastructure) — the Repository Pattern lesson's port-and-adapter split. • All queries use PDO prepared statements; multi-table writes wrapped in explicit transactions (SQL Injection Prevention; Transactions and Data Integrity). • Versioned up()/down() migrations, applied in CI against a fresh database (Database Migrations lesson). • Composite index on (project_id, status) sized to the actual hot query, per Query Performance and Indexing. • Schema changes that affect running code follow expand/contract, per Zero-Downtime Schema Changes.

Phase 4 — Application Services and the Dependency Injection Container

Between the HTTP layer and the domain sits the application layer: use-case services like CreateTaskService and CompleteTaskService, each with a single public method that represents one thing a user can do. These services depend only on interfaces — TaskRepositoryInterface, ClockInterface, EventDispatcherInterface — injected through the constructor, never instantiated with new inside the service body. This is constructor injection as taught in the Dependency Injection lesson, and it's what makes the service testable in isolation: a unit test can hand CreateTaskService an in-memory fake repository and assert on behavior without touching a real database.

Wiring all of these concrete implementations to their interfaces happens in exactly one place: a service container configured at bootstrap, following the Service Containers and Autowiring lesson. The container is told 'when something asks for TaskRepositoryInterface, hand it a PdoTaskRepository built with the shared PDO connection' and 'when something asks for CacheInterface, hand it a RedisCache built with the shared Redis client.' Controllers and services never talk to the container directly (that would be the service-locator anti-pattern, explicitly called out as a mistake below) — the container only ever appears at the composition root, resolving the full object graph once per request.

This layering is what makes the architecture 'hexagonal': the domain and application layers form the inside of the hexagon and are framework-agnostic, while HTTP, PDO, Redis, and the queue are adapters plugged into named ports (interfaces) from the outside. Swapping PostgreSQL for MySQL, or a REST controller for a CLI command that calls the same CreateTaskService, touches only the outer layer — exactly the payoff promised in the Hexagonal Architecture lesson.

• Application services take dependencies as constructor-injected interfaces, never new concrete classes internally (Dependency Injection lesson). • A single service container, configured once at bootstrap, resolves the object graph (Service Containers and Autowiring lesson). • Controllers depend on services, not on the container itself — avoids the service-locator anti-pattern. • Swapping an adapter (database engine, transport) never requires changing Domain or Application code.

Phase 5 — Authentication, Authorization, the REST API Surface, and Secrets Management

TaskForge authenticates API clients with short-lived bearer tokens issued at login, following the Token-Based Authentication lesson rather than server-side sessions, since the API is meant to be called by both a JS frontend and third-party integrations that can't rely on cookies. Passwords are hashed with password_hash() using the default (currently Argon2id-backed) algorithm and verified with password_verify(), never a hand-rolled hash, per the Password Storage and Hashing lesson. Authorization is layered on top as a separate concern: an AuthorizationMiddleware resolves the authenticated user's role within the organization (Owner, Admin, Member, modeled as another backed enum) and a Permission enum enumerates discrete capabilities like TaskDelete or ProjectArchive, checked with match in a small PermissionChecker service — the Role-Based Access Control lesson's core lesson that authentication (who are you) and authorization (what can you do) must never be conflated into one check.

The API itself is versioned in the URL path (/api/v1/...) as covered in the API Versioning Strategies lesson, specifically so that a future breaking change (say, restructuring the task JSON shape) can ship as /api/v2/... while existing integrations keep working unmodified against v1 until they choose to migrate. Every list endpoint is paginated with limit/cursor query parameters rather than returning unbounded result sets, following the Pagination Patterns lesson's preference for cursor-based pagination on a frequently-mutated table like tasks, since offset-based pagination would produce skipped or duplicated rows as tasks are created and completed between page requests.

Input validation happens at the boundary, before any domain code runs: a CreateTaskRequest DTO validates and normalizes the incoming JSON body (required fields, string length limits, enum membership for status) and rejects malformed requests with a 422 and a structured, field-keyed error body, exactly matching the API Error Response Design lesson's convention. Output encoding is symmetric: nothing from user input is ever echoed back into an HTML context unescaped (this API is JSON-only, but the admin dashboard that consumes it uses htmlspecialchars() on every dynamic value it renders, per the Cross-Site Scripting Prevention lesson), and the few state-changing endpoints reachable from the session-authenticated admin dashboard carry a CSRF token validated per the CSRF Protection lesson, since bearer-token API calls are immune to CSRF but cookie-authenticated form posts are not.

None of this works safely if credentials leak into source control, so configuration follows the Environment Configuration and Secrets Management lesson directly: no database password, Redis URL, or token-signing secret ever appears in a committed file. A .env file is used for local development only and is listed in .gitignore; production environments inject the same variable names through the platform's own secrets store (container orchestrator secrets, a cloud secrets manager, or CI/CD-injected environment variables at deploy time). The application reads configuration through a single typed Config object built once at bootstrap rather than scattering getenv() calls everywhere, so a missing required variable fails loudly at startup instead of surfacing as a confusing failure three layers deep at request time. And because TaskForge's dependency tree (a PSR-7 implementation, a database driver, a queue client) is an attack surface, not a free lunch, composer audit runs in CI on every pull request per the Dependency Vulnerability Scanning lesson, with a known high-severity advisory against a direct or transitive dependency failing the build — the same merge-blocking philosophy applied to PHPStan in the next phase — against a composer.lock that's committed so every environment installs identical resolved versions.

• Bearer-token API auth (Token-Based Authentication); password_hash()/password_verify() only (Password Storage and Hashing). • Authorization is a distinct layer: role enum + permission enum + PermissionChecker (Role-Based Access Control lesson). • URL path versioning (/api/v1) isolates breaking changes (API Versioning Strategies lesson). • Cursor-based pagination avoids skip/duplicate rows on a mutating table (Pagination Patterns lesson). • Boundary validation returns structured 422 errors; CSRF tokens protect the cookie-authenticated admin dashboard only (API Error Response Design; CSRF Protection). • All secrets come from environment variables / a platform secrets store, read via one typed Config object that fails fast on startup (Environment Configuration and Secrets Management lesson). • composer audit runs in CI as a merge-blocking gate against a committed composer.lock (Dependency Vulnerability Scanning lesson).

Phase 6 — The Testing Pyramid and PHPStan as a CI Gate

TaskForge's test suite is shaped like the pyramid taught in the Testing Pyramid for PHP Applications lesson: a large base of fast unit tests exercising domain logic and application services in isolation (using PHPUnit, with fake in-memory repositories standing in for TaskRepositoryInterface), a smaller layer of integration tests that run real SQL against a disposable test database to verify the PDO repositories and migrations actually work together, and a thin top layer of feature tests that boot the full HTTP stack (via a PSR-7 test request) and assert on an actual API response body and status code for a handful of critical user journeys — sign up, create a task, complete a task. This shape exists because unit tests are cheap to write and run in milliseconds, so they should catch the overwhelming majority of logic bugs, while full end-to-end feature tests are slow and brittle and are reserved for confirming the seams actually connect.

Static analysis runs alongside, not instead of, tests: PHPStan is configured at a high strictness level (following the Static Analysis with PHPStan lesson) and run as its own required CI step. It catches an entire category of bugs tests often miss by omission — a typo'd property access, a method call on a type that might be null, an enum match missing a newly-added case — before a single test even runs, and because it's a required status check, a pull request that introduces a new PHPStan error cannot merge, exactly like the composer audit gate in Phase 5.

CI runs static analysis and the full pyramid in that order — lint, then PHPStan, then unit, then integration, then feature — deliberately fastest-and-cheapest first, so a developer gets feedback on a trivial style or type mistake in seconds rather than waiting minutes for a full integration suite to spin up a database just to report the same typo.

• Unit tests (fast, isolated, fakes) form the base; integration tests (real DB) the middle; feature tests (full HTTP stack) the thin top (Testing Pyramid for PHP Applications lesson). • PHPStan runs at high strictness as a required, merge-blocking CI step (Static Analysis with PHPStan lesson). • CI order is cheapest-first: lint → PHPStan → unit → integration → feature.

Phase 7 — Caching and Background Jobs: Redis and the Queue

The project list endpoint — 'all projects for this organization with task counts' — is read far more often than the underlying data changes, which is exactly the profile the Caching Strategies with Redis lesson identifies as ideal for a cache-aside layer: on a cache miss, RedisProjectSummaryCache computes the summary from the database, stores it in Redis with a short TTL, and serves from Redis on subsequent requests; any write that changes a project's task count actively invalidates that specific cache key rather than waiting for the TTL, so the cache is a performance layer, never a second source of truth. The cache is accessed only through a CacheInterface the application layer depends on, so the same cache-aside logic could be unit-tested against an in-memory fake exactly as with the repository pattern.

Anything that shouldn't block the HTTP response — sending an invitation email when a teammate is added, or regenerating a project's activity digest — is pushed onto a background queue instead of running inline, following the Background Job Queues lesson. The controller enqueues a small, serializable job payload (an organization ID and a user ID, not a hydrated object graph) and returns its response immediately; a separate worker process pulls jobs off the queue and executes them, retrying with backoff on transient failure and moving a job that fails repeatedly to a dead-letter queue for manual inspection rather than retrying it forever. This is also why an idempotent job handler matters: at-least-once delivery means the 'send invitation email' job could run twice, so the handler checks whether the invitation was already sent before sending it again.

• Cache-aside pattern via a CacheInterface port: read-through on miss, explicit invalidation on write (Caching Strategies with Redis lesson). • Slow, non-critical-path work (email, digests) is enqueued, not run inline (Background Job Queues lesson). • Workers retry with backoff and route persistently-failing jobs to a dead-letter queue. • Job handlers are written idempotently to tolerate at-least-once delivery.

Phase 8 — Docker, CI/CD, and Health-Checked Deployment

TaskForge ships as a multi-stage Docker image, following the Multi-Stage Docker Builds lesson: a composer stage installs dependencies with dev tooling included, a separate build stage runs no application code at all (it exists purely to keep the final image lean), and the final runtime stage copies in only the vendor autoload output and application source, running as a non-root user with no Composer, no dev dependencies, and no build tooling present — shrinking both the image size and the attack surface. The CI pipeline (Phase 6's gates, plus a build step) runs on every pull request; the CD step runs only after CI is green on the main branch, builds and pushes the image, and deploys it behind a rolling update.

The rolling deployment itself uses the pattern from the Blue-Green and Rolling Deployment lesson combined with the readiness-endpoint pattern from the Health Checks in Depth lesson: the orchestrator brings up new-version containers, waits for each one's /readyz endpoint to report healthy (meaning it has a working database connection and can reach Redis) before routing any live traffic to it, and only then begins terminating old-version containers — so a bad deploy that crashes on startup, or one that starts but can't reach its database, never receives real user traffic in the first place. /healthz (liveness — 'is the process alive at all') and /readyz (readiness — 'can this instance actually serve a request right now') are deliberately kept as two separate, narrowly-scoped endpoints rather than one combined check, exactly as that lesson distinguishes them, because conflating the two would cause the orchestrator to kill and restart perfectly healthy instances during a brief downstream Redis blip instead of just pausing traffic to them.

Schema migrations run as a separate, gated CD step before the new application version receives traffic — not automatically inside the container's startup command — so that a migration failure halts the deploy with a clear signal rather than surfacing as a mysteriously half-broken running application; and, per Phase 3, any migration that isn't purely additive is written expand/contract so that the previous and new application versions can both run correctly against the database mid-rollout.

• Multi-stage Dockerfile: composer stage → build stage → minimal non-root runtime stage (Multi-Stage Docker Builds lesson). • CI gates (lint, PHPStan, composer audit, tests) must pass before CD builds and pushes an image. • Rolling deploy waits on /readyz before routing traffic; old containers terminate only after new ones are healthy (Blue-Green and Rolling Deployment; Health Checks in Depth). • /healthz (liveness) and /readyz (readiness) are separate endpoints with separate meanings. • Migrations run as a gated pre-traffic CD step, written expand/contract for compatibility mid-rollout.

Phase 9 — Observability: Structured Logs, Correlation IDs, and Metrics

Once TaskForge is running in production, the question stops being 'does it work' and becomes 'can we tell, right now, what it's doing and whether it's healthy' — the concern of the Structured Logging and Correlation IDs lesson. Every incoming request is assigned a correlation ID at the edge (read from an X-Correlation-ID header if the caller supplied one, generated otherwise) and that ID is attached to every log line the request produces, all the way down through the application and infrastructure layers, by passing it through the same request-scoped context object rather than as an extra parameter threaded through every function signature. Logs themselves are emitted as structured JSON (level, message, correlation ID, user ID, timestamp, and relevant context fields) rather than free-text lines, specifically so they can be queried — 'show me every log line for correlation ID X' turns a scattered, multi-service incident into a single ordered timeline in seconds.

A small set of application metrics — request count and latency by route and status code, queue depth, cache hit rate — are exported following the Metrics and Instrumentation Basics lesson, giving the team a dashboard that answers 'is this slow right now' without needing to grep logs first; logs and metrics are complementary, not redundant: metrics tell you *that* p99 latency on POST /api/v1/tasks just spiked, and the correlation-ID-tagged structured logs let you find the exact slow requests and see why.

Put together, Phases 1 through 9 form a closed loop: the architecture in Phases 1–4 makes the system easy to reason about and change; Phase 5 makes it safe to expose to the internet; Phase 6 makes it safe to change quickly; Phase 7 makes it fast under real load; Phase 8 makes it safe to ship; and Phase 9 makes it possible to know, once it's shipped, whether all of the above is actually working.

• A correlation ID is generated (or propagated) per request and attached to every log line it produces (Structured Logging and Correlation IDs lesson). • Logs are structured JSON, queryable by field, not free-text. • Latency, error rate, queue depth, and cache hit rate are exported as metrics (Metrics and Instrumentation Basics lesson). • Metrics answer 'is something wrong'; correlation-tagged logs answer 'why', for the same incident.

What Completing This Capstone Demonstrates

Building TaskForge end to end is the practical proof of the entire course's arc. Starting from PHP fundamentals — variables, control flow, functions — the course moved through object-oriented PHP and modern language features, then into professional tooling (Composer, PSR standards, autoloading), then into architecture (hexagonal design, dependency injection, repositories), then into the concerns that separate a toy script from a real product: correct and safe persistence, authentication and authorization, a well-designed API, defense against the standard web attack classes, automated verification through tests and static analysis, performance under real traffic through caching and async work, and finally the operational discipline of shipping and running the thing safely — containerization, CI/CD, and observability.

A learner who has built this capstone can credibly say, in a technical interview or a design review, that they can take a business requirement ('teams need to manage tasks together') and produce a system that is testable in isolation at every layer, safe to expose publicly, safe to change without fear because CI enforces both correctness and code quality, fast enough under load because caching and async work are used where they earn their keep, and observable enough in production that an on-call engineer can diagnose an incident from logs and metrics rather than guesswork. That is the actual, durable skill this entire course has been building toward: not memorizing PHP syntax, but being able to design, secure, test, optimize, deploy, and operate a production-grade PHP system — the same system, built the same way, that this capstone project has just walked through end to end.

• Demonstrated: modern PHP language mastery applied idiomatically, not just known in isolation. • Demonstrated: professional project structure via Composer/PSR-4 and layered/hexagonal architecture. • Demonstrated: safe persistence, authentication/authorization, and a well-designed versioned API. • Demonstrated: security discipline (validation, encoding, CSRF, secrets, dependency scanning). • Demonstrated: automated quality gates (testing pyramid, PHPStan) and performance engineering (caching, queues). • Demonstrated: shipping and operating discipline (Docker, CI/CD, health checks, structured observability).

Code Examples

This is the Readonly Properties and Immutability lesson and the Enums for Domain Modeling lesson working together: identity/creation fields are locked at construction with readonly, status is a backed enum instead of a raw string, and each transition method uses an exhaustive match with no default arm, so PHPStan flags every transition method the moment a new TaskStatus case is added anywhere without being handled here.

TaskStatus::Completed => throw new InvalidTaskTransitionException(...)

The Domain layer only knows TaskRepositoryInterface; PdoTaskRepository is the only class in the whole codebase that knows SQL exists. Every query uses bound parameters (never string interpolation), the multi-table save() is wrapped in an explicit transaction with rollback on failure, and the pagination query is written to exactly match the composite index described in Phase 3.

WHERE project_id = :project_id AND status = :status AND (:cursor IS NULL OR id > :cursor)

Every dependency arrives through the constructor as an interface, never as a concrete class instantiated with new inside the method — this is what lets a unit test hand handle() an in-memory fake repository, fake cache, and fake queue and assert on behavior with zero database or Redis involved.

private readonly TaskRepositoryInterface $tasks, The controller is thin by design: it validates the request shape via a DTO (returning a structured 422 on failure, per the API Error Response Design lesson), delegates all business logic to CreateTaskService, translates a domain exception into the correct HTTP status, and the route itself is versioned under /api/v1 so a future v2 response shape can ship without breaking this one.

return JsonResponse::create(status: 422, body: ['errors' => $dto->errors()]);

Following the Multi-Stage Docker Builds lesson, only the final stage ships in the deployed image: no Composer binary, no dev dependencies, no build tooling — just the optimized autoloader, application code, and a non-root user, minimizing both image size and attack surface.

Every step here is a required status check, so a pull request cannot merge if any of them fail — composer audit and phpstan analyse are just as much merge-blocking gates as the tests are. The order runs cheapest and fastest first (lint, then static analysis) before spending time booting a database for integration and feature tests.

vendor/bin/phpstan analyse --level=9 src

<?php
declare(strict_types=1);

namespace TaskForge\Domain\Task;

use TaskForge\Domain\Exception\InvalidTaskTransitionException;
use TaskForge\Domain\Project\ProjectId;
use TaskForge\Domain\User\UserId;
use DateTimeImmutable;

enum TaskStatus: string
{
    case Open = 'open';
    case InProgress = 'in_progress';
    case Completed = 'completed';
    case Archived = 'archived';
}

final class Task
{
    private TaskStatus $status;

    private function __construct(
        public readonly TaskId $id,
        public readonly ProjectId $projectId,
        public readonly DateTimeImmutable $createdAt,
        private string $title,
        private ?UserId $assignee,
        TaskStatus $status,
    ) {
        $this->status = $status;
    }

    public static function create(
        TaskId $id,
        ProjectId $projectId,
        string $title,
        ?UserId $assignee = null,
    ): self {
        return new self(
            id: $id,
            projectId: $projectId,
            createdAt: new DateTimeImmutable(),
            title: $title,
            assignee: $assignee,
            status: TaskStatus::Open,
        );
    }

    public function status(): TaskStatus
    {
     
…
<?php
declare(strict_types=1);

namespace TaskForge\Domain\Task;

interface TaskRepositoryInterface
{
    public function findById(TaskId $id): ?Task;

    public function save(Task $task): void;

    /** @return Task[] */
    public function findOpenByProjectPaginated(
        ProjectId $projectId,
        int $limit,
        ?string $cursor,
    ): array;
}

namespace TaskForge\Infrastructure\Persistence;

use PDO;
use TaskForge\Domain\Task\{Task, TaskId, TaskRepositoryInterface};
use TaskForge\Domain\Project\ProjectId;

final class PdoTaskRepository implements TaskRepositoryInterface
{
    public function __construct(private readonly PDO $pdo) {}

    public function save(Task $task): void
    {
        $this->pdo->beginTransaction();
        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO tasks (id, project_id, title, status, assignee_id, created_at)
                 VALUES (:id, :project_id, :title, :status, :assignee_id, :created_at)
                 ON DUPLICATE KEY UPDATE title = :title2, status = :status2, assignee_id = :assignee_id2'
            );
            $stmt->execute([
                'id' => $task->id->toString(),
                'proj
…
<?php
declare(strict_types=1);

namespace TaskForge\Application\Task;

use TaskForge\Domain\Task\{Task, TaskId, TaskRepositoryInterface};
use TaskForge\Domain\Project\ProjectId;
use TaskForge\Application\Cache\CacheInterface;
use TaskForge\Application\Queue\QueueInterface;
use TaskForge\Application\Clock\ClockInterface;

final class CreateTaskService
{
    public function __construct(
        private readonly TaskRepositoryInterface $tasks,
        private readonly CacheInterface $cache,
        private readonly QueueInterface $queue,
        private readonly ClockInterface $clock,
    ) {}

    public function handle(ProjectId $projectId, string $title, ?string $assigneeId = null): Task
    {
        $task = Task::create(
            id: TaskId::generate(),
            projectId: $projectId,
            title: $title,
            assignee: $assigneeId !== null ? UserId::fromString($assigneeId) : null,
        );

        $this->tasks->save($task);

        // Invalidate the cached project summary rather than waiting on its TTL.
        $this->cache->delete("project_summary:{$projectId->toString()}");

        if ($assigneeId !== null) {
            // Non-critical work (notificati
…
<?php
declare(strict_types=1);

namespace TaskForge\Http\Controller\V1;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TaskForge\Application\Task\CreateTaskService;
use TaskForge\Http\Response\JsonResponse;
use TaskForge\Http\Request\CreateTaskRequest;
use TaskForge\Domain\Exception\InvalidTaskTransitionException;
use TaskForge\Domain\Project\ProjectId;

final class TaskController
{
    public function __construct(private readonly CreateTaskService $createTask) {}

    // POST /api/v1/projects/{projectId}/tasks
    public function create(ServerRequestInterface $request, string $projectId): ResponseInterface
    {
        $dto = CreateTaskRequest::fromArray((array) $request->getParsedBody());

        if (!$dto->isValid()) {
            return JsonResponse::create(
                status: 422,
                body: ['errors' => $dto->errors()],
            );
        }

        try {
            $task = $this->createTask->handle(
                projectId: ProjectId::fromString($projectId),
                title: $dto->title,
                assigneeId: $dto->assigneeId,
            );
        } catch (InvalidTaskTransitionException $e) {
 
…
# ---- Stage 1: install dependencies with Composer (dev tools included) ----
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-scripts --no-interaction --prefer-dist

# ---- Stage 2: production-only autoload optimization ----
FROM composer:2 AS vendor-prod
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist \
    --optimize-autoloader

# ---- Stage 3: minimal runtime image ----
FROM php:8.3-fpm-alpine AS runtime
RUN docker-php-ext-install pdo pdo_mysql opcache \
    && addgroup -S taskforge && adduser -S taskforge -G taskforge

WORKDIR /var/www/html
COPY --from=vendor-prod /app/vendor ./vendor
COPY src ./src
COPY public ./public
COPY --chown=taskforge:taskforge . .

USER taskforge
EXPOSE 9000
HEALTHCHECK --interval=10s --timeout=3s CMD php -r "exit(0);"
CMD ["php-fpm", "-F"]
name: ci
on: [pull_request]

jobs:
  quality-gates:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: taskforge_test
        ports: ["3306:3306"]
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Dependency vulnerability scan
        run: composer audit --locked

      - name: Lint (PSR-12)
        run: vendor/bin/phpcs --standard=PSR12 src tests

      - name: Static analysis
        run: vendor/bin/phpstan analyse --level=9 src

      - name: Run migrations against test database
        run: php bin/migrate.php --env=test

      - name: Unit tests
        run: vendor/bin/phpunit --testsuite=unit

      - name: Integration tests
        run: vendor/bin/phpunit --testsuite=integration

      - name: Feature tests
        run: vendor/bin/phpunit --testsuite=feature

TaskForge Request Path Through the Hexagon

Dependencies point inward: Controller → Application → Domain, with Infrastructure plugged into Domain-declared interfaces from the outside — the hexagonal shape in concrete form.

HTTP request (Psr\Http\Message\ServerRequestInterface)
                              │
                    ┌─────────▼─────────┐
                    │  Middleware:        │  authn → authz → CSRF
                    │  PSR-15 pipeline    │  (Phase 5)
                    └─────────┬─────────┘
                              │
                    ┌─────────▼─────────┐
                    │  Http\Controller   │  validate DTO, map to service call
                    └─────────┬─────────┘
                              │  calls
                    ┌─────────▼─────────┐
                    │ Application\*Service│  orchestrates domain + ports
                    └───┬────────┬──────┘
                        │        │
              ┌─────────▼──┐   ┌─▼───────────────┐
              │  Domain\Task│   │ Ports (interfaces)│
              │  entity/enum│   │ Repository/Cache/  │
              │  + rules    │   │ Queue/Clock        │
              └─────────────┘   └────────┬──────────┘
                                          │ implemented by
                              ┌───────────▼───────────┐
                              │ Infrastructure adapters │
                              │ PdoTaskRe
…

This Is How Real PHP SaaS Platforms Are Actually Built

Production PHP platforms at companies running real B2B SaaS — project-management tools, support-desk software, e-commerce backends — are built from precisely this set of ingredients, not from a single framework's 'magic': a clear layered or hexagonal boundary so business logic survives a framework upgrade, PSR-backed interoperable tooling so the dependency tree can evolve piece by piece, a CI pipeline where static analysis and dependency scanning are as non-negotiable as tests, a cache and queue used surgically on the handful of code paths that actually need them rather than everywhere, and a deploy process that treats a health check as the gate between 'the code is on the server' and 'the code is receiving traffic.' TaskForge is a teaching-sized version of exactly that system, and the reasoning behind each phase transfers directly to a real job.

class CreateTaskService { public function handle(...) { $repo = new PdoTaskRepository($GLOBALS['pdo']); //... } } class CreateTaskService { public function __construct(private readonly TaskRepositoryInterface $tasks) {} public function handle(...) { /* uses $this->tasks */ } }

Instantiating a concrete infrastructure class (or reaching into a global) directly inside an application service silently breaks the hexagonal boundary and makes the service impossible to unit-test without a real database — the whole point of depending on TaskRepositoryInterface via constructor injection is lost.

class TaskController { public function create(Request $r) { $service = Container::get(CreateTaskService::class); //... } } class TaskController { public function __construct(private readonly CreateTaskService $createTask) {} }

Pulling dependencies out of the container inside a controller method is the service-locator anti-pattern: it hides the class's real dependencies from its constructor signature, defeats static analysis of what a class needs, and makes tests reach for a full container instead of a plain constructor call. The container should only ever build the object graph once, at the composition root.

ALTER TABLE tasks DROP COLUMN assignee_id, ADD COLUMN assignee_ids JSON;

-- Migration 1: ADD TABLE task_assignees (dual-write from app code) -- Migration 2 (later, after backfill + read-cutover): DROP COLUMN assignee_id

A single migration that drops a column the currently-running (previous-version) application still reads breaks every in-flight request during a rolling deploy. The expand/contract approach from Zero-Downtime Schema Changes adds new structure first, migrates reads and writes, and only removes the old structure once nothing depends on it.

$stmt = $pdo->query("SELECT * FROM tasks WHERE project_id = '$projectId'");

$stmt = $pdo->prepare('SELECT * FROM tasks WHERE project_id = :project_id'); $stmt->execute(['project_id' => $projectId]);

Interpolating a variable directly into SQL text is a textbook SQL injection vector, regardless of whether $projectId 'looks like' a safe UUID at the call site today — a future caller might not validate it the same way, and prepared statements cost nothing to use everywhere.

public function healthz(): Response { $db->query('SELECT 1'); $redis->ping(); return new Response(200); }

// /healthz: process is alive, no dependency checks. // /readyz: checks DB + Redis before reporting ready to receive traffic.

Combining liveness and readiness into a single check causes the orchestrator to kill and restart a perfectly healthy process during a brief Redis blip, instead of just pausing traffic to it — Health Checks in Depth treats these as two endpoints with two distinct meanings for exactly this reason.

Why does the Domain layer in TaskForge declare TaskRepositoryInterface instead of directly using PdoTaskRepository? A. Interfaces run faster than concrete classes in PHP B. It keeps the domain framework- and database-agnostic, so infrastructure can change without touching business logic C. PHP requires every class to implement an interface D. It avoids having to write a class body at all

B. It keeps the domain framework- and database-agnostic, so infrastructure can change without touching business logic — This is the hexagonal architecture's dependency rule: the domain depends only on an interface it owns, and a concrete adapter (PdoTaskRepository) implements it from the outside, so swapping the database or testing with a fake never requires changing domain code.

In Task::complete(), why does the match expression over TaskStatus deliberately omit a default arm? A. default arms are not legal PHP syntax in match expressions B. Omitting default makes PHPStan flag any new enum case that isn't explicitly handled here, catching an unhandled state at analysis time C. It makes the match expression run faster D. match expressions require at least four arms to compile

B. Omitting default makes PHPStan flag any new enum case that isn't explicitly handled here, catching an unhandled state at analysis time — An exhaustive match with no default arm is a deliberate technique: adding a new TaskStatus case anywhere in the codebase later will cause static analysis to fail everywhere that status isn't handled, which is far safer than a default arm that silently swallows unexpected cases.

Why does PdoTaskRepository::save() wrap its two INSERT statements in an explicit PDO transaction? A. Transactions make INSERT statements run in parallel B. PDO requires a transaction for any query that uses named parameters C. It guarantees the task row and its audit-log row are written together or not at all, preventing a half-written state on failure D. It disables foreign key checks for the duration of the query

C. It guarantees the task row and its audit-log row are written together or not at all, preventing a half-written state on failure — Wrapping a multi-table write in beginTransaction/commit/rollBack is exactly the guarantee taught in the Transactions and Data Integrity lesson: if the second statement fails, the transaction rolls back so the first write is undone rather than leaving inconsistent data.

Why does TaskForge version its API as /api/v1/... rather than putting a version number only in response headers or nowhere at all? A. URL versioning is required by the HTTP specification B. It lets a future breaking change ship as /api/v2/... while existing integrations keep working against v1 unmodified C. It makes the API faster to parse D. It is the only way PHP routers can distinguish endpoints

B. It lets a future breaking change ship as /api/v2/... while existing integrations keep working against v1 unmodified — Path-based API versioning, from the API Versioning Strategies lesson, isolates breaking changes behind a new path so old clients are never forced to update on the platform's schedule.

What is the key difference between /healthz and /readyz in TaskForge's health-check design? A. They are identical; /readyz is just a legacy alias B. /healthz checks the database while /readyz checks only the process C. /healthz reports whether the process is alive at all, while /readyz additionally checks that dependencies like the database are reachable and the instance can actually serve traffic D. /readyz is only used in local development, never in production

C. /healthz reports whether the process is alive at all, while /readyz additionally checks that dependencies like the database are reachable and the instance can actually serve traffic — Liveness (/healthz) and readiness (/readyz) answer different questions; conflating them causes an orchestrator to kill a process during a transient downstream blip instead of just withholding traffic from it, as covered in Health Checks in Depth.

Why is a schema change that removes a column split into an 'expand' migration and a later 'contract' migration instead of one combined migration? A. Because MySQL does not support dropping columns B. So that both the old and new application versions running simultaneously during a rolling deploy can each correctly read/write the schema they expect C. Because migrations can only ever add columns, never remove them D. To make the migration file shorter

B. So that both the old and new application versions running simultaneously during a rolling deploy can each correctly read/write the schema they expect — During a rolling deploy, old and new code run against the same database briefly. Expand/contract adds new structure first (compatible with both versions), then removes the old structure only after every instance has cut over — a single destructive migration would break the still-running old version.

In CreateTaskService, why is sending an assignment notification pushed onto a queue instead of being sent synchronously before the method returns? A. PHP cannot send emails synchronously B. It keeps the request-response cycle fast by deferring non-critical, potentially slow work to a background worker, following the Background Job Queues lesson C. Queued jobs are guaranteed to run exactly once, unlike synchronous code D. The email service only accepts requests from a queue, never from PHP directly

B. It keeps the request-response cycle fast by deferring non-critical, potentially slow work to a background worker, following the Background Job Queues lesson — Work that doesn't need to complete before the HTTP response is sent — like a notification email — is enqueued so the API stays fast; the tradeoff is that queue consumers must be idempotent since most queues offer at-least-once, not exactly-once, delivery.

Why does the CI pipeline run composer audit and phpstan analyse as required, merge-blocking steps rather than advisory warnings? A. Because GitHub Actions does not support warning-only steps B. Treating dependency vulnerabilities and static-analysis errors as hard gates prevents known-vulnerable dependencies or type errors from ever reaching main, the same discipline applied to the test suite C. Because PHPStan and composer audit are slower than the test suite and must run last D. Because PHP has no other way to detect a vulnerable dependency

B. Treating dependency vulnerabilities and static-analysis errors as hard gates prevents known-vulnerable dependencies or type errors from ever reaching main, the same discipline applied to the test suite — A CI gate that only warns is routinely ignored under deadline pressure; making composer audit and PHPStan required status checks — just like the automated tests — is what actually keeps known vulnerabilities and type errors out of the main branch.

What problem does cursor-based pagination solve on the task-listing endpoint that offset-based pagination does not? A. Cursor pagination is faster to implement B. It avoids skipped or duplicated rows across pages when tasks are created or completed between page requests on a frequently-mutated table C. It removes the need for an index on the tasks table D. It allows returning results in random order

B. It avoids skipped or duplicated rows across pages when tasks are created or completed between page requests on a frequently-mutated table — Offset-based pagination recalculates 'skip N rows' on every request, so rows inserted or removed between page fetches shift what falls on each page; cursor-based pagination anchors to a stable row identifier instead, which is stable under concurrent writes.

Extend TaskForge with a new capability end to end, touching every layer covered in this capstone: add task 'comments' (a teammate can comment on a task). Model Comment as a domain entity with a readonly id/createdAt and a CommentRepositoryInterface port; implement a PdoCommentRepository with a proper index on (task_id, created_at) for the 'list comments for a task, oldest first' query; add an AddCommentService that enqueues a 'notify mentioned users' background job when a comment contains an @mention; expose POST /api/v1/tasks/{taskId}/comments and GET /api/v1/tasks/{taskId}/comments (cursor-paginated) through a versioned, validated, authorization-checked controller; invalidate any cached task summary on comment creation; write unit tests for AddCommentService against a fake repository and a feature test that posts a comment through the full HTTP stack; and confirm phpstan analyse --level=9 and composer audit both stay clean.

<?php // Sketch only — mirrors the Task/CreateTaskService/TaskController shapes above. namespace TaskForge\Domain\Comment; final class Comment { private function __construct( public readonly CommentId $id, public readonly TaskId $taskId, public readonly UserId $authorId, public readonly DateTimeImmutable $createdAt, private string $body, ) {} public static function create(TaskId $taskId, UserId $authorId, string $body): self { return new self(CommentId::generate(), $taskId, $authorId, new DateTimeImmutable(), $body); } public function mentionedUsernames(): array { preg_match_all('/@([a-zA-Z0-9_]+)/', $this->body, $m); return $m[1]; } } interface CommentRepositoryInterface { public function save(Comment $comment): void; /** @return Comment[] */ public function findByTaskPaginated(TaskId $taskId, int $limit, ?string $cursor): array; }

Reuse the exact shapes already in this lesson: copy CreateTaskService's constructor-injection pattern for AddCommentService, copy PdoTaskRepository's transaction/prepared-statement pattern for PdoCommentRepository, and copy TaskController's DTO-validate-then-delegate pattern for the new comment endpoints — the goal is recognizing the repeating shape, not inventing a new one.