Add Lib\Session: native session wrapper with flash data

Lib\Session (novaconium/lib/Session.php) is a thin, all-static wrapper
around PHP's native session handling — get/set/has/remove plus
CodeIgniter-style flash data (flash()/getFlash()): a value set now is
readable on exactly the next request, then gone, for post/redirect/GET
flows like the contact form's hand-rolled ?sent=1 (not refactored here —
the original spec cites it as a motivating example, not a mandate).

Lazy-start, same shape as the already-shipped Lib\Csrf, which the two
classes can share a native session with in the same request without
conflict. Flash data is a single per-request swap (snapshot last
request's bucket, clear the stored one) rather than a sweep/expiry pass.

Verified end-to-end across three separate HTTP requests sharing a cookie
jar (not just in-process calls), confirming a flashed value survives
exactly one subsequent request.

Closes the "Session handling (with flash sessions)" backlog item in
novaconium/ISSUES.md.
This commit is contained in:
code
2026-07-14 06:43:11 +00:00
parent 3a59269eaa
commit 5deb298b91
7 changed files with 243 additions and 39 deletions
+27
View File
@@ -191,6 +191,33 @@ tables of its own yet — if a future framework feature needs a shipped
migration, extend this to the same App-over-novaconium two-root scan
`Overlay.php` already does for pages/lib, don't invent a second mechanism.
`Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around
native PHP sessions (`session_start()`/`$_SESSION`, not a custom store),
all-static and lazy-start like `Lib\Csrf` — nothing calls `session_start()`
until the first real call to a `Session` method. Its `ensureSession()` is a
**deliberate duplicate** of `Csrf::ensureSession()` (same cookie params,
same `session_status()` guard) rather than a shared helper — keeps `Csrf`
standalone with zero new dependencies on a class that didn't exist when it
shipped, same tolerance for small duplication already established by the
config-load block duplicated across `bootstrap.php`/`bin/clear-cache.php`/
`Lib\Db::config()`. Both classes touching the same native session in the
same request is safe either way, since `session_start()` silently no-ops
if a session is already active — there's no ordering requirement between
`Csrf::token()`/`::verify()` and any `Session` method.
Flash data (`Session::flash()`/`::getFlash()`) is one swap, not a
sweep/expiry pass: the first `Session` method call in a request snapshots
whatever was flashed on the *previous* request into an in-memory static
(`self::$currentFlash`) for that request's `getFlash()` reads, then
immediately empties the stored flash bucket so `flash()` calls made
*during* the current request start filling a fresh bucket for the request
after this one. This relies on static properties not persisting across
requests (true under `php -S`, mod_php, and PHP-FPM alike — each request
gets fresh PHP state regardless of worker-process reuse) — don't add any
caching/memoization to `Session` that assumes static state survives
between requests, since none of it does. See `/admin/docs/session` for a
worked flash example.
## Running it
```
+4 -1
View File
@@ -17,6 +17,7 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
- **Sessions with flash data** — `Lib\Session`, a thin wrapper around native PHP sessions with CodeIgniter-style flash values (set now, readable on exactly the next request) for post/redirect/GET flows without a query-string flag. Lazy-start, same mechanism `Lib\Csrf` already uses. See `/admin/docs/session`.
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
## Getting started
@@ -77,12 +78,14 @@ php novaconium/bin/create-static-page.php blog/my-new-post
## Documentation
The full framework documentation — routing, sidecars, libraries, layouts, static caching, SEO, Matomo analytics, admin authentication, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
The full framework documentation — routing, sidecars, libraries, database, session, layouts, static caching, SEO, Matomo analytics, admin authentication, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
- [Routing](http://127.0.0.1:8000/admin/docs/routing)
- [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars)
- [Libraries](http://127.0.0.1:8000/admin/docs/libraries)
- [Database](http://127.0.0.1:8000/admin/docs/database)
- [Session](http://127.0.0.1:8000/admin/docs/session)
- [Layouts](http://127.0.0.1:8000/admin/docs/layouts)
- [Static caching](http://127.0.0.1:8000/admin/docs/caching)
- [SEO](http://127.0.0.1:8000/admin/docs/seo)
+48 -36
View File
@@ -54,55 +54,37 @@ expected vs. actual behavior. For features, include the motivating use case.>
Suggested build order (foundations first, since admin login builds on two
of the others):
1. **Session handling** — no dependencies.
2. **Blog tags/categories** — no dependencies, but now needs a metadata
1. **Blog tags/categories** — no dependencies, but now needs a metadata
source design decision first (`PostRepository` was removed when
`hello-world`/`second-post` became plain Twig pages — see the entry
below), so worth doing first or together with Blog RSS feed.
3. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
2. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
once tags/categories exist.
4. **Internal search** — SQLite groundwork it needed is done; ready to
3. **Internal search** — SQLite groundwork it needed is done; ready to
build.
5. **XML sitemap** — no hard dependency, but shares crawling logic with
4. **XML sitemap** — no hard dependency, but shares crawling logic with
Internal search, so easiest right after (or alongside) it.
6. **Media/file manager** — no hard dependency; usable standalone, though
5. **Media/file manager** — no hard dependency; usable standalone, though
best gated behind admin login once that exists.
7. **Draft pages (admin-only preview)** — no hard dependency; the admin
6. **Draft pages (admin-only preview)** — no hard dependency; the admin
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
8. **Syntax highlighting on code blocks** — no hard dependency on the
7. **Syntax highlighting on code blocks** — no hard dependency on the
copy button (see Done — shipped 2026-07-14), but touches the same
`<pre><code>` markup it did.
9. **Admin login & user management** — SQLite groundwork it needed is
done; still needs session handling (logged-in state).
10. **Ecommerce functionality** — needs session handling (cart) and admin
login & user management (order/product admin, and customer accounts);
SQLite groundwork it needed is done.
11. **Paywall functionality** — needs everything Ecommerce needs, plus
8. **Admin login & user management** — SQLite groundwork and session
handling it needed are both done; ready to build.
9. **Ecommerce functionality** — needs admin login & user management
(order/product admin, and customer accounts); SQLite groundwork and
session handling (cart) it needed are both done.
10. **Paywall functionality** — needs everything Ecommerce needs, plus
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
build after it rather than in parallel.
MySQL support shipped 2026-07-14 — see Done.
MySQL support and Session handling (with flash sessions) shipped
2026-07-14 — see Done.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
### Session handling (with flash sessions)
- **Type:** Feature
- **Status:** Backlog
- **Priority:** High
- **Added:** 2026-07-12
A `Lib\`/framework-core wrapper around PHP's native session handling
(`session_start()` etc., not a custom session store) so sidecars and the
future admin login have a consistent way to read/write session data
instead of touching `$_SESSION` directly. Include CodeIgniter-style flash
data — a value set now that survives exactly one subsequent request (e.g.
`$session->flash('message', 'Saved.')` readable on the next request only,
via a set-now/expire-after-read scheme) — for post/redirect/GET flows like
`App/pages/contact/index.php` already does manually with `?sent=1`.
Foundational alongside SQLite groundwork — no dependencies of its own, but
admin login depends on it.
### Blog tags/categories
- **Type:** Feature
@@ -287,7 +269,7 @@ full of stray markup.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions)
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done)
- **Added:** 2026-07-12
A single-user HTTP Basic Auth stopgap now gates `/admin/*`
@@ -306,7 +288,7 @@ with the new mechanism, not layering on top of it.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions), Admin login & user management
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
- **Added:** 2026-07-12
Product catalog, cart, checkout, and order storage — a `products` /
@@ -326,7 +308,7 @@ planning it all up front here.
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions), Admin login & user management
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management
- **Added:** 2026-07-12
Subscription/membership content gating, similar to OnlyFans/Patreon:
@@ -349,6 +331,36 @@ _Nothing yet._
## Done
### Session handling (with flash sessions)
- **Type:** Feature
- **Status:** Done
- **Priority:** High
- **Added:** 2026-07-12
- **Shipped:** 2026-07-14
A `Lib\` wrapper around PHP's native session handling (`session_start()`,
`$_SESSION`, not a custom session store) — `Lib\Session`
(`novaconium/lib/Session.php`), all-static and lazy-start, same shape as
the already-shipped `Lib\Csrf` (which also touches the native session; the
two coexist in the same request without conflict). Ships
`get()`/`set()`/`has()`/`remove()` plus CodeIgniter-style flash data
(`flash()`/`getFlash()`) — a value set now that's readable on exactly the
next request, then gone, for post/redirect/GET flows like
`App/pages/contact/index.php`'s hand-rolled `?sent=1` (not refactored to
use it in this change — cited in the original spec as the motivating
example, not a mandate to touch working demo code). The flash mechanism is
a single per-request swap (snapshot last request's flash bucket into an
in-memory static on first touch, then clear the stored bucket so this
request's `flash()` calls fill a fresh one for the request after), not a
separate expiry/sweep pass — verified end-to-end across three real,
separate HTTP requests sharing a cookie jar (not three calls in one PHP
process), confirming a flashed value appears on exactly the next request
and is gone on the one after. Foundational alongside SQLite groundwork —
admin login (next up) depends on it for logged-in state. Documented at
`/admin/docs/session` and in `AGENTS.md` next to the `Lib\Csrf`/`Lib\Db`
sections.
### MySQL support
- **Type:** Feature
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace Lib;
/**
* A thin wrapper around PHP's native session handling — session_start()
* etc., not a custom session store — so sidecars have a consistent
* get/set/flash API instead of touching $_SESSION directly. All-static,
* lazy-start like Lib\Csrf: nothing calls session_start() until the first
* real call to any method here, so a page that never touches Session (or
* Csrf, which starts a session the same way) never gets a session cookie.
*
* ensureSession()'s body is deliberately duplicated from Csrf::ensureSession()
* rather than extracted into a shared helper — keeps Csrf standalone with
* zero new dependencies rather than coupling it to a class that didn't
* exist when it shipped, consistent with this project's tolerance for
* small duplication over premature coupling (see the config-load block
* duplicated across bootstrap.php/bin/clear-cache.php/Lib\Db::config()).
* Both classes touching the same native session in the same request is
* safe either way — session_status() guards against a double session_start().
*
* Flash data: a value set now via flash() is readable via getFlash() on
* exactly the next request, then gone — for post/redirect/GET flows like
* "message sent" banners, without a query-string flag. See
* /admin/docs/session for the mechanism and a worked example.
*/
final class Session
{
private const FLASH_KEY = '_flash';
private static bool $flashLoaded = false;
/** @var array<string, mixed> */
private static array $currentFlash = [];
public static function get(string $key, mixed $default = null): mixed
{
self::ensureSession();
return $_SESSION[$key] ?? $default;
}
public static function set(string $key, mixed $value): void
{
self::ensureSession();
$_SESSION[$key] = $value;
}
public static function has(string $key): bool
{
self::ensureSession();
return isset($_SESSION[$key]);
}
public static function remove(string $key): void
{
self::ensureSession();
unset($_SESSION[$key]);
}
/**
* Stores $value so it's readable via getFlash($key) on the next
* request only, then gone — regardless of whether getFlash() was
* actually called on that next request.
*/
public static function flash(string $key, mixed $value): void
{
self::ensureSession();
$_SESSION[self::FLASH_KEY][$key] = $value;
}
/**
* Reads a value flashed on the previous request. Never reflects a
* value flashed during this same request — that value will be
* readable on the next request instead.
*/
public static function getFlash(string $key, mixed $default = null): mixed
{
self::ensureSession();
return self::$currentFlash[$key] ?? $default;
}
private static function ensureSession(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
// Must be called before session_start() — after is a silent no-op.
session_set_cookie_params([
'httponly' => true,
'samesite' => 'Lax',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
]);
session_start();
}
// Runs once per request, on whichever Session method is called
// first: snapshot last request's flash bucket for this request's
// getFlash() reads, then immediately reset the session's bucket so
// flash() calls made during this request go to a fresh bucket —
// the one the *next* request will snapshot. This single swap is
// the entire flash mechanism; no separate expiry/sweep step needed,
// since static properties don't persist across requests.
if (!self::$flashLoaded) {
self::$currentFlash = $_SESSION[self::FLASH_KEY] ?? [];
$_SESSION[self::FLASH_KEY] = [];
self::$flashLoaded = true;
}
}
}
@@ -14,6 +14,7 @@
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a></li>
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a></li>
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a></li>
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a></li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
+3 -2
View File
@@ -4,7 +4,7 @@
{% block title %}Docs{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, layouts, caching, styling.{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, layouts, caching, styling.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
@@ -18,7 +18,8 @@
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> — plain PHP classes under <code>Lib\</code>.</li>
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a> — override framework settings from <code>App/config.php</code>.</li>
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO/SQLite wrapper with a plain-SQL migration convention.</li>
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.</li>
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.</li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
@@ -0,0 +1,46 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Session{% endblock %}
{% block description %}Lib\Session — a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Session</h1>
<p><code>Lib\Session</code> (<code>novaconium/lib/Session.php</code>) is a thin wrapper around PHP's native session handling — plain <code>session_start()</code>/<code>$_SESSION</code>, not a custom session store — so sidecars have a consistent get/set API instead of touching <code>$_SESSION</code> directly. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Session.php</code>.</p>
<h2>Using it</h2>
<pre><code>use Lib\Session;
Session::set('user_id', 42);
$userId = Session::get('user_id'); // 42
$loggedIn = Session::has('user_id'); // true
Session::remove('user_id');</code></pre>
<p>The session is started lazily — nothing calls <code>session_start()</code> until the first real call to any <code>Session</code> method, so a page that never touches <code>Session</code> never gets a session cookie. <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Csrf</a> uses the exact same lazy-start mechanism to run its own session-token CSRF protection — both classes can touch the same native session in the same request without conflict, since <code>session_start()</code> is only ever actually called once (PHP no-ops a second call).</p>
<h2>Flash data</h2>
<p>A flashed value is readable on exactly the next request, then gone — useful for post/redirect/GET flows (a "message sent" banner after a redirect) without a query-string flag like <code>?sent=1</code>:</p>
<pre><code>use Lib\Session;
use App\Response;
// In the sidecar handling the POST:
Session::flash('message', 'Sent! We\'ll be in touch soon.');
return Response::redirect('/contact');
// In the sidecar handling the following GET (the redirect target):
return [
'flashMessage' =&gt; Session::getFlash('message'),
];</code></pre>
<p><code>Session::getFlash($key, $default = null)</code> returns the value on the request immediately after <code>flash()</code> was called, and the default on every request after that — regardless of whether <code>getFlash()</code> was actually called on that one request in between. A value flashed during the current request is never visible to <code>getFlash()</code> during that same request; it becomes visible on the next one.</p>
<p>Mechanically, this is a single swap rather than a separate expiry/sweep step: the first time any <code>Session</code> method runs in a request, it snapshots whatever was flashed on the previous request into an in-memory value for that request's <code>getFlash()</code> calls, then immediately clears the stored flash bucket so <code>flash()</code> calls made during the current request start filling a fresh bucket — the one the next request will snapshot in turn.</p>
{% endblock %}