Putting It Together

One small application that uses every stage: a validated form, prepared statements, a session login, a JSON endpoint, tests and a deploy.

Syntaxphp -S 127.0.0.1:8000 -t public

Each chapter so far taught one piece. This one is the assembly. The project is a sign-up and account page small enough to read in an afternoon and complete enough to deploy.

The shape on disk

  • public/ - the document root: index.php, health.php, ready.php, and static assets.
  • src/ - Db (a PDO factory with exceptions on and emulation off), Csrf, SignupValidator, UserRepository.
  • templates/ - HTML where every value printed goes through htmlspecialchars().
  • tests/, composer.json, Dockerfile, deploy/.

The order a request runs in

Start the session. Reject anything that is not the expected method. Check the CSRF token, which proves the form came from your page. Validate the input and re-render the form with escaped error messages if it fails. Only then touch the database, through a prepared statement. Hash the password with password_hash(). Call session_regenerate_id(true) at the moment the user becomes logged in, so a stolen pre-login id is worthless. Redirect with 303 rather than echoing a page after a write.

Done means deployable

The validator and the repository have tests. The image builds. display_errors is off and log_errors is on. The release is a symlink swap followed by an FPM reload and a call to /ready. You have run the rollback once, on purpose, before needing it.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Build a complete sign-up and login flow in plain PHP, with no framework.
  • Use as the reference for a code review checklist: prepared statements, escaped output, CSRF token, regenerated session.
  • Take a project from a local folder to a deployed release with health checks and a tested rollback.

More examples

The POST handler, start to finish

Read it top to bottom as a list of refusals: wrong method, wrong token, invalid input, and only then real work. Every exit after a status code matters, because PHP otherwise carries on and sends a body that contradicts the header. The 303 after a successful write means a reload of the account page does not resubmit the form. session_regenerate_id(true) deletes the old session file as well as issuing a new id.

Example · php
<?php

declare(strict_types=1);

// public/index.php, the sign-up branch.
require __DIR__ . '/../vendor/autoload.php';

use Shop\Csrf;
use Shop\Db;
use Shop\SignupValidator;
use Shop\UserRepository;

session_start();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    header('Allow: POST');
    exit;
}

if (!Csrf::check($_POST['_token'] ?? '')) {
    http_response_code(400);
    exit('Your session expired. Reload the form and try again.');
}

$errors = SignupValidator::check($_POST);

if ($errors !== []) {
    http_response_code(422);
    require __DIR__ . '/../templates/signup.php';
    exit;
}

$users = new UserRepository(Db::connect());
$userId = $users->create(
    (string) $_POST['email'],
    password_hash((string) $_POST['password'], PASSWORD_DEFAULT)
);

session_regenerate_id(true);
$_SESSION['user_id'] = $userId;

header('Location: /account', true, 303);
exit;

The repository, with every query prepared

Nothing from the outside is ever concatenated into the SQL: the placeholders carry the values and the database never parses them as syntax. Because the class takes its PDO instance in the constructor, a test can hand it a connection to a throwaway database and the production code is unchanged. findByEmail returns the hash, never a plain password, and the login route compares with password_verify().

Example · php
<?php

declare(strict_types=1);

namespace Shop;

use PDO;

final class UserRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    public function create(string $email, string $passwordHash): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (email, password_hash, created_at)
             VALUES (:email, :hash, :created_at)'
        );

        $stmt->execute([
            'email' => $email,
            'hash' => $passwordHash,
            'created_at' => '2026-10-04 09:00:00',
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    public function findByEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, email, password_hash FROM users WHERE email = :email'
        );
        $stmt->execute(['email' => $email]);

        return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
    }
}

The tests that let you deploy on a Friday

The validator is a pure function of an array, so testing it needs no server, no session and no database, and the suite runs in milliseconds. Three cases is the useful minimum: one failure per rule and one success, because a validator that rejects everything also passes every rejection test. Run this in the build, before the image is pushed, and a broken rule never reaches a release directory.

Example · php
<?php

declare(strict_types=1);

namespace Shop\Tests;

use PHPUnit\Framework\TestCase;
use Shop\SignupValidator;

final class SignupValidatorTest extends TestCase
{
    public function testRejectsAShortPassword(): void
    {
        $errors = SignupValidator::check([
            'name' => 'Ada',
            'email' => '[email protected]',
            'password' => 'short',
        ]);

        $this->assertArrayHasKey('password', $errors);
    }

    public function testRejectsAMalformedEmail(): void
    {
        $errors = SignupValidator::check([
            'name' => 'Ada',
            'email' => 'ada@@example',
            'password' => 'correct horse battery',
        ]);

        $this->assertArrayHasKey('email', $errors);
    }

    public function testAcceptsAValidSignup(): void
    {
        $errors = SignupValidator::check([
            'name' => 'Ada',
            'email' => '[email protected]',
            'password' => 'correct horse battery',
        ]);

        $this->assertSame([], $errors);
    }
}

Discussion

  • Be the first to comment on this lesson.