A small starter for PHP sites on Linux, Nginx (the "E"), Postgres and PHP, run with Docker Compose. No framework, no ORM, no Composer packages: a router of about 160 lines, a PDO wrapper of about 60, plain PHP views, and SQL files you write yourself.
  • PHP 90%
  • CSS 6.7%
  • Dockerfile 1.8%
  • Hack 1.3%
  • Shell 0.2%
Find a file
Josh 2e638ffe7a
Some checks failed
ci / test (push) Has been cancelled
docs: credit phprouter as the inspiration for the router
2026-10-12 13:50:39 -05:00
.github/workflows ci: lint and test on push and pull request 2026-10-12 13:50:39 -05:00
data feat(nginx): hide the version and send basic security headers 2026-10-12 13:50:39 -05:00
html style: remove an em dash from the Router header comment 2026-10-12 13:50:39 -05:00
tests test: add a dependency-free test runner for helpers, DB and HTTP behavior 2026-10-12 13:50:39 -05:00
.env.example docs: rewrite README and tidy .env.example and .gitignore 2026-10-12 13:50:39 -05:00
.gitignore docs: rewrite README and tidy .env.example and .gitignore 2026-10-12 13:50:39 -05:00
docker-compose.yaml fix(db): safe transactions, real prepared statements, credentials out of the DSN 2026-10-12 13:50:39 -05:00
LICENSE Add LICENSE 2025-01-04 19:59:03 +00:00
README.md docs: credit phprouter as the inspiration for the router 2026-10-12 13:50:39 -05:00

LEPP Core

A small starter for PHP sites on Linux, Nginx (the "E"), Postgres and PHP, run with Docker Compose. No framework, no ORM, no Composer packages: a router of about 160 lines, a PDO wrapper of about 60, plain PHP views, and SQL files you write yourself.

It is a scaffold to copy and build on, not a framework. The routing style ($param segments and plain function handlers) is inspired by phprouter.

Quick start

You need Docker with the Compose plugin.

cp .env.example .env
docker compose up --build

Then open http://localhost for the app and http://localhost:15432 for pgAdmin (log in with PGEMAIL and PGPASSWORD from .env). Postgres and pgAdmin only listen on 127.0.0.1, and PHP-FPM is only reachable from the nginx container.

The values in .env.example are throwaway development defaults. Change them before exposing anything beyond your own machine.

What is in the box

Piece Where Notes
Router html/core/Router.php get, post, put, patch, delete, any, $param segments, before and after hooks, automatic 404
Database html/core/DB.php PDO with prepared statements: DB::query, one, all, transaction
Views html/views/ Plain PHP with shared partials; e() escapes output
API example html/api/ping.php JSON endpoint at /api/{id}
CSRF Router::set_csrf() Hidden input for forms, checked with Router::is_csrf_valid()
Logging log_line() Request log written to LOG_DIR, outside the web root
Tests tests/run.php No dependencies, see below

Add a page

// html/core/routes.php
Router::get('/about', fn() => render_view('/about.php', ['title' => 'About']));
Router::get('/users/$id', function ($id) {
    $user = DB::one('SELECT * FROM users WHERE id = ?', [$id]);
    $user ? render_view('/user.php', ['user' => $user]) : http_response_code(404);
});

Always escape values in views with <?= e($value) ?>, and put <?php Router::set_csrf(); ?> inside every POST form (see html/views/home.php and FormController).

Add tables

Put your schema in data/scripts/tables.sql and any migration logic in data/scripts/initial-migration.sh. They are mounted into the database container at /var/www/scripts. There is no migration framework on purpose.

How requests flow

nginx serves static files from html/ and sends everything else to html/index.php. Direct requests to any other .php file return 403. index.php loads the helpers, middleware and routes, and the router runs the first match.

nginx -> index.php -> utils/functions.php -> core/middleware.php -> core/routes.php -> controller -> view

Configuration

Setting Where Purpose
POSTGRES_* .env Database user, password, name, host and port
PGEMAIL, PGPASSWORD, PGPORT .env pgAdmin login and local port
TIME_ZONE .env PHP time zone
LOG_DIR compose php service Where request logs go (default /var/log/php, a named volume)
data/php/app.ini repo PHP overrides on top of the official php.ini-production

Security notes

  • Output is escaped with e(), queries use prepared statements, and forms are CSRF protected.
  • Session cookies are HttpOnly and SameSite=Lax (and Secure over HTTPS).
  • nginx hides its version and sends X-Content-Type-Options, X-Frame-Options and Referrer-Policy.
  • There is no TLS, login or user table here. Put a TLS-terminating proxy in front for anything public, and add authentication before storing user data.

Tests

You need PHP 8.2 or newer with pdo_sqlite:

php tests/run.php

The runner starts PHP's built-in server and makes real HTTP requests, so it checks routing, 404 pages, CSRF rejection, form validation and escaped output end to end. Database helpers are tested against in-memory SQLite. CI runs a syntax check and the tests on every push.

The Docker setup (compose file, Dockerfile and nginx config) is not covered by the test runner.

License

MIT, see LICENSE.