This tutorial takes the kip/skeleton app template, routing,
sessions, and auth already wired up, nothing else opinionated, and turns it
into a working blog: post index and detail pages, a login-only editor,
guest comments, pagination, and the audit log and page cache running
underneath the whole time. By the end you'll have rebuilt
examples/blog yourself, one small step at a time.
Every code block below is copy-paste-runnable, and by the end every file
matches what's actually in examples/blog byte for byte (two blocks along
the way are explicitly labeled interim versions that a later step
replaces, and the migration files differ only in their numbered
filenames, as Step 1 explains). If you'd rather read the finished app
than type it, it's sitting right there.
/posts, a paginated list of posts/posts/show/<id>, a post with its comments/posts/createand/posts/edit/<id>, a login-only editor, gated by the login you already have from the skeleton/comments/store/<postId>. A guest comment form, no login required- Along the way: the request-log audit trail, the page cache with its
X-Kip-Cacheheader, and the login throttle that already came with the skeleton's auth battery
- PHP 8.3 or newer
- Composer
- About an hour, and a terminal
No database server to install: Kip defaults to SQLite, and the files just appear the first time you run a migration.
git clone <this repo> kip && cd kip/skeleton
composer install
php bin/kip migrateThen create your account. The password is asked for interactively with the terminal echo off, so it never lands in your shell history (run this on its own, not pasted as part of a bigger block, or the next pasted line becomes your password):
php bin/kip user:create you@example.comAnd start the dev server:
php bin/kip serveOpen http://localhost:8080. You should see the skeleton's welcome page, with a working "Log in" link. Try it with the account you just created (once you're in, the home page grows a "Log out" button).
Honest note on that first line: kip/framework isn't on Packagist yet, so
skeleton/composer.json resolves it via a local path repository, which only
works from inside a checkout of this repo. Once the framework is published,
starting a new app becomes the one-liner composer create-project kip/skeleton blog from anywhere, no clone required. Nothing else about
this tutorial changes when that happens.
Keep bin/kip serve running in that terminal and open a second terminal in
the same skeleton/ directory for the rest of this tutorial, you'll edit
files and reload the browser as you go, no restart needed.
Kip migrations are plain PHP classes with an up() and a down(), tracked
by name in a _migrations table so bin/kip migrate only ever runs each one
once. The skeleton ships seven, 001_create_users.php through
007_index_login_attempts_by_time.php: the auth battery's tables and the
indexes its login throttle needs. Number your own migrations from where
those leave off, so posts is 008.
Create app/migrations/008_create_posts.php:
<?php // app/migrations/008_create_posts.php
return new class extends Kip\Migrations\Migration {
public function up(Kip\Database $db): void
{
$db->query('CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL
)');
}
public function down(Kip\Database $db): void { $db->query('DROP TABLE posts'); }
};Run it:
php bin/kip migrateRan: 008_create_posts
(examples/blog's own posts migration is numbered 002. That app was
built before the framework/skeleton/example split existed, so its migrations
never had to share a directory with the skeleton's auth battery. Same SQL
either way; only the number and the reader's own migration history differ.)
Create app/src/Controllers/PostsController.php:
<?php // app/src/Controllers/PostsController.php
namespace App\Controllers;
use Kip\Database;
use Kip\Http\Response;
use Kip\View;
use Kip\Routing\{Auth, Post};
use Kip\{Session, Http\Request};
final class PostsController
{
private const PER_PAGE = 20;
public function __construct(private Database $db, private View $view, private Session $session, private Request $request) {}
public function index(): string
{
$page = max(1, (int) $this->request->str('page'));
$posts = $this->db->all(
'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?',
[self::PER_PAGE + 1, ($page - 1) * self::PER_PAGE] // fetch one extra: cheap has-next probe, no COUNT(*)
);
$hasNext = count($posts) > self::PER_PAGE;
$posts = array_slice($posts, 0, self::PER_PAGE);
return $this->view->render('posts/index', ['title' => 'Posts', 'posts' => $posts, 'page' => $page, 'hasNext' => $hasNext]);
}
public function show(string $id): Response|string
{
$post = $this->db->one('SELECT * FROM posts WHERE id = ?', [$id]);
if ($post === null) return new Response('Post not found', 404);
return $this->view->render('posts/show', ['title' => $post['title'], 'post' => $post]);
}
}Ignore the pagination fields for a moment, we'll get there in Step 7. The
constructor and the #[Auth]/#[Post] imports that aren't used yet are
the whole controller's final shape; there's no harm in typing them once
now. show() above is an interim version: in Step 6 its query also
fetches the post's comments, once the comments table actually exists.
Querying it now would throw, since the table isn't there yet.
How it works: Router::match() (src/Routing/Router.php) turns a URL
into Class::method(args) by convention, no route table to maintain.
/posts/show/3 splits into posts (StudlyCase → PostsController),
show (the method name), and 3 (the first positional argument). The
constructor's typed parameters (Database $db, View $view, Session $session, Request $request) are autowired by Kip\Container, you never
construct a controller yourself.
Visit http://localhost:8080/posts now, you'll get an empty list (no error;
there's just nothing to show yet). /posts/show/1 will 404 until Step 5
gives you a way to create a post. That's expected, and once you do create
one you'll land straight on its show page, still running this interim
show().
Views are plain PHP with one convention worth internalizing immediately:
always escape output with $this->e(), which is htmlspecialchars()
under the hood (Kip\View::e()). It's not automatic. Kip doesn't compile
templates or auto-escape. So the discipline is yours to keep.
Create app/views/posts/index.php:
<?php // app/views/posts/index.php ?>
<?php $this->layout('layout'); ?>
<h1>Posts</h1>
<ul>
<?php foreach ($posts as $p): ?>
<li><a href="/posts/show/<?= $this->e($p['id']) ?>"><?= $this->e($p['title']) ?></a></li>
<?php endforeach; ?>
</ul>
<nav>
<?php if ($page > 1): ?><a href="/posts?page=<?= $this->e($page - 1) ?>">← Newer</a><?php endif; ?>
<?php if ($hasNext): ?><a href="/posts?page=<?= $this->e($page + 1) ?>">Older →</a><?php endif; ?>
</nav>And app/views/posts/show.php, an interim version, matching the
interim show() above (no comments yet):
<?php // app/views/posts/show.php ?>
<?php $this->layout('layout'); ?>
<article>
<h1><?= $this->e($post['title']) ?></h1>
<p><?= nl2br($this->e($post['body'])) ?></p>
</article>Step 6 replaces both this file and show() with their final versions, once
there's a comments table and a partial to include. Until then this is a
complete, working post page on its own. Nothing here is broken or
incomplete to look at.
How it works: $this->layout('layout') (called from inside the
template. $this is the Kip\View instance rendering it) tells
Kip\View::render() to wrap the template's output in
app/views/layout.php, making the rendered content available there as
$content. Every array key you pass to render() becomes a local variable
in the template ($posts, $page, $post, ...) via extract(), with one
reserved exception: keys starting with __kip_ are silently dropped, so
your own data never collides with the renderer's internal variables.
Missing template → Kip\TemplateNotFoundException, not a blank page.
Two small edits so the new pages are actually reachable and the homepage reads like a blog instead of a bare skeleton.
In app/views/layout.php, change the title default and nav:
<?php // app/views/layout.php ?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $this->e($title ?? 'Blog') ?></title>
<link rel="stylesheet" href="/style.css">
<style>@view-transition { navigation: auto; }</style>
<script type="speculationrules">{"prerender": [{"where": {"href_matches": "/*"}, "eagerness": "conservative"}]}</script>
</head>
<body>
<header>
<nav>
<a href="/">My Blog</a>
<a href="/posts">Posts</a>
<a href="/posts/create">Write</a>
</nav>
</header>
<main><?= $content ?></main>
</body>
</html>(The /posts/create link doesn't exist until Step 5, it'll 404 until
then, which is fine for a nav link you're about to make real.)
Replace app/src/Controllers/HomeController.php whole, the skeleton's
home page carried a logged-in check and a logout form, but the blog moves
logging out to the editor page you'll build in Step 5, so the blog's
HomeController is simpler than the skeleton's:
<?php // app/src/Controllers/HomeController.php
namespace App\Controllers;
use Kip\View;
final class HomeController
{
public function __construct(private View $view) {}
public function index(): string
{
return $this->view->render('home/index', ['title' => 'My Blog']);
}
}And simplify app/views/home/index.php to match:
<?php // app/views/home/index.php ?>
<?php $this->layout('layout'); ?>
<h1>Hello from Kip</h1>Reload http://localhost:8080/, new nav, new homepage. /posts still
works from Step 2.
This is where routing conventions meet PHP attributes. Add create,
store, edit, and update to PostsController:
#[Auth]
public function create(): string
{
return $this->view->render('posts/edit', ['title' => 'New post', 'post' => ['id' => null, 'title' => '', 'body' => ''], 'csrf' => $this->session->csrfToken()]);
}
#[Auth] #[Post]
public function store(): Response
{
$this->db->query('INSERT INTO posts (title, body, created_at) VALUES (?, ?, ?)',
[$this->request->str('title'), $this->request->str('body'), date('c')]);
return Response::redirect('/posts/show/' . $this->db->lastInsertId());
}
#[Auth]
public function edit(string $id): Response|string
{
$post = $this->db->one('SELECT * FROM posts WHERE id = ?', [$id]);
if ($post === null) return new Response('Post not found', 404);
return $this->view->render('posts/edit', ['title' => 'Edit post', 'post' => $post, 'csrf' => $this->session->csrfToken()]);
}
#[Auth] #[Post]
public function update(string $id): Response
{
$this->db->query('UPDATE posts SET title = ?, body = ? WHERE id = ?',
[$this->request->str('title'), $this->request->str('body'), $id]);
return Response::redirect("/posts/show/{$id}");
}(These four methods sit alongside index() and the interim show() from
Step 2 in the same class. PostsController has all six methods now,
though show() itself is still the interim version until Step 6.)
Create app/views/posts/edit.php: one form for both create and edit,
switching its action based on whether $post['id'] is set:
<?php $this->layout('layout'); ?>
<h1><?= $this->e($title) ?></h1>
<form method="post" action="<?= $post['id'] ? '/posts/update/' . $this->e($post['id']) : '/posts/store' ?>">
<input type="hidden" name="_token" value="<?= $this->e($csrf) ?>">
<label>Title <input name="title" value="<?= $this->e($post['title']) ?>" required></label>
<label>Body <textarea name="body" required><?= $this->e($post['body']) ?></textarea></label>
<button>Save</button>
</form>
<form method="post" action="/auth/logout">
<input type="hidden" name="_token" value="<?= $this->e($csrf) ?>">
<button>Log out</button>
</form>How it works, the convention router and its attributes
(src/Routing/Router.php, and one file per attribute such as
src/Routing/Auth.php):
- A method with no verb attribute is GET-only by convention.
#[Post](and#[Put]/#[Delete], unused here) restrict it to that verb, hitstore()with a GET and you get a 405 Method Not Allowed, not a 404, with anAllowheader naming the verbs that would work. #[Auth]marks a method as requiring a logged-in session. Hit/posts/createwhile logged out and the kernel redirects you to/auth/loginbefore your controller code runs at all. You never write that check yourself.- Every non-
GETrequest is also checked for a CSRF token.#[Auth]routes are strict about it: the session's token must arrive as_tokenin the POST body (never the query string) or the request is rejected with 403, no exceptions. That's what$this->session->csrfToken()(passed into the view ascsrf) and the hidden<input name="_token">are for.
One more small edit while we're here. The skeleton's AuthController was
written before there was anywhere better to send a freshly-logged-in user
than the homepage. Now that /posts exists, point a successful login
there instead. In app/src/Controllers/AuthController.php, in attempt():
if ($this->auth->attempt($email, $this->request->postStr('password'), $this->request->ip)) {
return Response::redirect('/posts');
}(That's the only line that changes. Response::redirect('/') becomes
Response::redirect('/posts'). Everything else in AuthController stays
exactly as the skeleton had it.)
Log in, then visit http://localhost:8080/posts/create. Write a post, save
it, and you'll land on its show page. Edit it from there by visiting
/posts/edit/<id> directly (there's no edit link in the UI yet, feel free
to add one).
Comments are guest-facing, no login required, which means they can't rely on a session-bound CSRF token (there's no session yet for an anonymous visitor). Add the migration, controller, and view for them.
app/migrations/009_create_comments.php:
<?php // app/migrations/009_create_comments.php
return new class extends Kip\Migrations\Migration {
public function up(Kip\Database $db): void
{
$db->query('CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL
)');
}
public function down(Kip\Database $db): void { $db->query('DROP TABLE comments'); }
};app/migrations/010_add_comments_post_id_index.php, a plain index
migration, split out on its own so it's easy to skip on a low-traffic app
and add later without touching the table's shape:
<?php // app/migrations/010_add_comments_post_id_index.php
return new class extends Kip\Migrations\Migration {
public function up(Kip\Database $db): void
{
$db->query('CREATE INDEX idx_comments_post_id ON comments (post_id, created_at, id)');
}
public function down(Kip\Database $db): void { $db->query('DROP INDEX idx_comments_post_id'); }
};php bin/kip migrateRan: 009_create_comments, 010_add_comments_post_id_index
app/src/Controllers/CommentsController.php:
<?php // app/src/Controllers/CommentsController.php
namespace App\Controllers;
use Kip\{Database, Http\Request, Http\Response};
use Kip\Routing\Post;
final class CommentsController
{
public function __construct(private Database $db, private Request $request) {}
#[Post]
public function store(string $postId): Response|string
{
$post = $this->db->one('SELECT * FROM posts WHERE id = ?', [$postId]);
if ($post === null) return new Response('Post not found', 404);
$author = $this->request->str('author');
$body = $this->request->str('body');
if ($author === '' || $body === '') {
return new Response('Author and comment are required.', 422);
}
$this->db->query('INSERT INTO comments (post_id, author, body, created_at) VALUES (?, ?, ?, ?)',
[$postId, $author, $body, date('c')]);
return Response::redirect("/posts/show/{$postId}");
}
}Now wire comments into the post page. This is where the interim
show() and show.php from Step 2/3 become final. In
PostsController, replace show() with:
public function show(string $id): Response|string
{
// One query per page: the comments arrive as a JSON array in the post's own row.
$post = $this->db->one("SELECT p.*,
(SELECT json_group_array(json_object('id', c.id, 'author', c.author,
'body', c.body, 'created_at', c.created_at))
FROM comments c WHERE c.post_id = p.id) AS comments_json
FROM posts p
WHERE p.id = ?", [$id]);
if ($post === null) return new Response('Post not found', 404);
// SUBSTITUTE: one comment with invalid UTF-8 would otherwise make json_decode() return null.
$comments = json_decode($post['comments_json'], true, flags: JSON_INVALID_UTF8_SUBSTITUTE);
usort($comments, fn ($a, $b) => [$a['created_at'], $a['id']] <=> [$b['created_at'], $b['id']]);
return $this->view->render('posts/show', [
'title' => $post['title'],
'post' => $post,
'comments' => $comments,
]);
}The page needs a post and its comments, and it gets both in one query:
SQLite's json_group_array() folds every comment into a JSON array
carried in the post's own row, and json_decode() turns it back into the
same list of rows a second query would have returned. A post with no
comments yields []. The sort happens in PHP because an aggregate does not
promise an order. The JSON_INVALID_UTF8_SUBSTITUTE flag keeps one comment
posted with a stray non-UTF-8 byte from breaking the whole page: the bad
bytes show as � instead of making json_decode() return null. Kip apps hold every page to one query against the
content database; chapter 15 explains
why and shows the other patterns.
And replace app/views/posts/show.php with:
<?php // app/views/posts/show.php ?>
<?php $this->layout('layout'); ?>
<article>
<h1><?= $this->e($post['title']) ?></h1>
<p><?= nl2br($this->e($post['body'])) ?></p>
</article>
<?php require __DIR__ . '/_comments.php'; ?>That last line references a partial you're about to write. Create
app/views/posts/_comments.php:
<?php // app/views/posts/_comments.php ?>
<section>
<h2>Comments</h2>
<?php foreach ($comments as $c): ?>
<p><strong><?= $this->e($c['author']) ?></strong>: <?= nl2br($this->e($c['body'])) ?></p>
<?php endforeach; ?>
<form method="post" action="/comments/store/<?= $this->e($post['id']) ?>">
<label>Name <input name="author" required></label>
<label>Comment <textarea name="body" required></textarea></label>
<button>Add comment</button>
</form>
</section>Notice there's no _token field in that form, deliberately. Guest
routes without #[Auth] skip the mandatory-token check and instead get a
same-origin check: the kernel looks at the Sec-Fetch-Site header (falling
back to Origin/Referer) and compares it against the request's own
Host. A cross-site POST fails that check and gets rejected with 403; a
same-origin form submission, like the one your browser just sent, passes
without ever needing a token. The one deliberate gap: a request carrying
none of those headers is accepted rather than rejected, because legacy
browsers and privacy-hardened clients look exactly like that, and the worst
case on a guest-only route is a spam-shaped submission, not a
state-changing attack against a logged-in session. docs/guide/06-security.md
covers this lane in full, including where it does and doesn't apply.
That "spam-shaped" worst case is exactly what the fixed-window rate limiter
owns, and guest comments should not wait for a flood to find out. In
config.php, add one key to the array it returns:
// config.php
'rate_limit' => ['comments' => ['max' => 30, 'window' => 60]],The rate_limits table it counts in ships with the skeleton. Every
non-GET request under /comments now costs one hit before routing: the
31st in a minute is answered 429 Too Many Requests with a Retry-After
header, and neither your controllers nor the page budget see it. Chapter
6 covers the whole limiter: per-prefix caps, the dashed/underscored
canonicalization that stops spelling games, and the max => 0 kill
switch.
Visit a post and leave a comment. It appears immediately, no login needed.
You already wrote this in Step 2. PostsController::index() fetches
self::PER_PAGE + 1 rows (21) and checks whether it got more than 20 back,
rather than running a separate COUNT(*). That's cheap at any table size.
Nothing left to do here except see it work: create 21+ posts (a quick loop
via /posts/create if you don't want to type that many, or seed the table
directly with SQLite) and /posts will show an "Older →" link; /posts?page=2
will show "← Newer" going back. Non-numeric or negative page values fall
back to page 1 rather than erroring.
One thing is missing: an index for that ORDER BY. Without one, SQLite
reads and sorts every post to return a page of 20. Create
app/migrations/011_add_posts_created_at_index.php:
<?php // app/migrations/011_add_posts_created_at_index.php
return new class extends Kip\Migrations\Migration {
public function up(Kip\Database $db): void
{
// The listing orders by created_at DESC, id DESC. SQLite appends the rowid to
// every index entry, so this one column covers the id tie-break too.
$db->query('CREATE INDEX idx_posts_created_at ON posts (created_at)');
}
public function down(Kip\Database $db): void { $db->query('DROP INDEX idx_posts_created_at'); }
};php bin/kip migrateRan: 011_add_posts_created_at_index
The listing now walks the index newest first and stops after 21 rows.
Everything below has been running since Step 1. You just haven't looked at it yet.
The audit log. Every request you've made, including the ones during
this tutorial, is in app/logs.sqlite:
php bin/kip logs2026-08-16T02:16:51+00:00 200 GET /posts 1.1ms ::1 guest
2026-08-16T02:17:03+00:00 302 POST /posts/store 2.4ms ::1 user:1
2026-08-16T02:17:11+00:00 200 GET /posts/show/1 1.0ms ::1 user:1
2026-08-16T02:17:20+00:00 302 POST /comments/store/1 1.8ms ::1 guest
Timestamp, status, method, path, timing, IP, and the user id if one was
logged in, nothing more. bin/kip logs and bin/kip migrate both prune
rows older than the retention window (log_db.retention_days in
config.php, default 30 days) every time they run, so the table doesn't
grow forever even if you forget to schedule the cron job in the deploy
section below.
The page cache. Guest GET requests are cached in app/cache.sqlite
and served with an X-Kip-Cache header telling you what happened:
curl -sI http://localhost:8080/posts/show/1 | grep -i x-kip-cacheX-Kip-Cache: MISS
Run the same curl again:
X-Kip-Cache: HIT
While rendering the miss, the kernel recorded which tables the page's
queries actually touched. posts and comments, since show() reads
both, and tagged the cached entry with them. Post a new comment on that
same post and the comments write purges every page tagged comments,
including this one:
curl -sI http://localhost:8080/posts/show/1 | grep -i x-kip-cacheX-Kip-Cache: MISS
Notice what doesn't get purged: /posts (the index) is tagged only
posts, since its query never touches comments, a comment on any post
leaves the index page's cache entry alone. That's the table-tag model in
one sentence: a cached page dies exactly when the data it actually read
changes, nothing more, nothing less.
Login throttling. The skeleton's auth battery came with this built in:
try it now. Log out, then submit the login form with a wrong password five
times in a row (or curl -X POST five times against /auth/attempt, note
/auth/attempt, not /auth/login, which only accepts GET). The first
five each show the ordinary "Wrong email or password" page. The sixth
request, even with the correct password, gets:
HTTP/1.1 429 Too Many Requests
with the login form re-rendered and "Too many attempts, try again in 15
minutes." Throttling keys on the email/IP pair over a 15-minute window and
resets on the next successful login. docs/guide/06-security.md covers the
one caveat worth knowing before you deploy behind a reverse proxy (the
KIP_TRUSTED_PROXY note below).
- Set
KIP_ENV=production(or leave it unset.prodis the default) so stack traces never reach the browser; useKIP_ENV=devonly on your own machine. - If TLS terminates at a reverse proxy in front of the app, set
KIP_TRUSTED_PROXY=1so the session cookie'ssecureflag and the login throttle's IP both key off the real client, not the proxy. - Run
php bin/kip migrateas part of every deploy, before traffic is routed to the new code. It's idempotent (already-applied migrations are skipped) so it's safe to run on every deploy, not just ones that add a migration. - Schedule log pruning independently of deploys, since a quiet app might not
redeploy for weeks:
0 3 * * * php /path/to/app/bin/kip logs:prune --days=30
docs/guide/10-deployment.md walks through a full VPS deploy shape.
PHP-FPM/nginx or php -S behind a reverse proxy, what to back up, and HTTPS
cookie behavior.
You've now touched routing, controllers, views, migrations, auth, CSRF (both
lanes), the audit log, and the page cache, the whole battery lineup except
the CLI reference. Read docs/guide/README.md for the
full user guide, chapter by chapter, or just keep building on the app you
already have.