Building a Service with NeNe¶
This tutorial shows the common implementation path for a small NeNe service.
Read this as a guide for a renovated legacy framework. NeNe intentionally keeps the familiar shape of older PHP frameworks: URL segments select a controller and action, HTML pages use actionAction() methods, and data access stays close to small mapper classes. The modern parts are added around that shape: method-specific REST handlers, API response catalogs, OpenAPI, tests, Docker, and explicit security defaults.
If you know frameworks such as CodeIgniter 2/3 or Zend Framework 1, the flow should feel familiar. The difference is that NeNe keeps the core much smaller and documents the modern safety rails you should add when building services.
Use it as a checklist when adding application behavior:
- Add a server-rendered page when users need HTML.
- Add method-specific REST handlers when React or another client needs JSON.
- Add database mapper/model code when the feature stores data.
- Add error codes, OpenAPI, and tests at the same time as the endpoint.
NeNe is intentionally small. Prefer explicit controller methods, clear data mappers, and focused tests over hidden framework behavior.
Treat class/xion/ as framework core. Most page, REST, service, mapper, and model work should happen in the application-side namespaces such as class/controller/, class/model/, class/db/, and class/func/. Do not change dispatcher or core behavior for a normal service feature.
Before You Start¶
Work from a GitHub Issue and a topic branch.
Run the local Docker environment when you need HTTP or database behavior.
Useful checks:
Add a Fixed Page¶
Use an HTML action when the browser should receive a server-rendered page.
Example goal:
Create a controller:
<?php
declare(strict_types=1);
namespace Nene\Controller;
use Nene\Xion\ControllerBase;
class PageController extends ControllerBase
{
protected function preAction()
{
$this->SESSION_CHECK = false;
}
public function aboutAction(): void
{
$this->setTitle('About NeNe');
$this->VIEW->setString('t_heading', 'About NeNe');
$this->VIEW->setString('t_body', 'A small legacy PHP framework.');
}
}
Add the template at:
{extends file='layout/app.tpl'}
{block name='content'}
<section class="page-about">
<h1>{$t_heading}</h1>
<p>{$t_body}</p>
</section>
{/block}
Optional page assets are discovered by convention. ControllerBase::setCSS() and setJS() check three locations in order — drop a file at any of them and it is auto-linked into the page without explicit registration:
| Path | Loaded for |
|---|---|
htdocs/css/{controller}.css |
every action of {controller} |
htdocs/css/{controller}/common.css |
every action of {controller} |
htdocs/css/{controller}/{action}.css |
only that one action |
The same three patterns apply to htdocs/js/... for JavaScript. Files are emitted into the layout's {foreach $t_css} / {foreach $t_js} blocks with a cache-busting query string. Manual addCSS() / addJS() calls on $this->VIEW remain available when you need a CDN URL or a file outside the convention.
The dispatcher resolves /page/about to PageController::aboutAction(). ControllerBase automatically chooses view/source/page/about.tpl when it exists.
URL controller segments must be a single lowercase word (page, note, bookmark), not kebab-case (private-note, bookmark-tag). The dispatcher forms the class name via ucfirst(strtolower($controller)) . 'Controller', and PHP class names cannot contain hyphens. Multi-word concepts should be joined: /privatenote/index → PrivatenoteController::indexAction(), not /private-note/index. Action segments follow the same rule.
For the full template, CSS, JavaScript, and auto-loading rules, see docs/frontend/assets.md.
Handle a Form POST¶
A server-rendered page often needs to accept a form submission. NeNe does not provide a separate actionPostAction() convention for HTML form posts — the dispatcher resolves POST /xxx/index like this:
- If
indexPostRestexists on the controller, the request is treated as a REST call (JSON in / JSON out, CSRF enforced). - Otherwise, the request falls through to
indexAction, regardless of HTTP method.
For a normal HTML form, do not define indexPostRest. Let the dispatcher hand POST to indexAction(), and branch on $this->method inside the controller. Use $this->request->getPost($key) to read form fields, and $this->location($uri) to redirect after a successful write (the post/redirect/get pattern).
Example goal:
GET /note/index → list view
POST /note/index → create a note, redirect to detail
GET /note/new → form view
Controller:
<?php
declare(strict_types=1);
namespace Nene\Controller;
use Nene\Database;
use Nene\Xion\ControllerBase;
class NoteController extends ControllerBase
{
protected function preAction(): void
{
$this->SESSION_CHECK = false; // public sandbox page
}
public function indexAction(): void
{
if ($this->method === 'POST') {
$this->handleCreate();
return;
}
$mapper = new Database\NoteMapper();
$this->setTitle('All notes');
$this->VIEW->setValues('t_notes', $mapper->findRows());
}
public function newAction(): void
{
$this->setTitle('New note');
$this->VIEW->setString('t_error', '');
}
private function handleCreate(): void
{
$title = trim((string)($this->request->getPost('title') ?? ''));
$body = trim((string)($this->request->getPost('body') ?? ''));
if ($title === '' || $body === '') {
// Re-render the form template with an error message. The
// auto-selected template for indexAction is note/index.tpl;
// setTemplate() switches to note/new.tpl for the re-render.
$this->VIEW
->setString('t_error', 'Title and body are both required.')
->setString('t_form_title', $title)
->setString('t_form_body', $body)
->setTemplate('note/new.tpl');
return;
}
$id = (new Database\NoteMapper())->create($title, $body);
$this->location('/note/item/id_' . $id);
}
}
Form template (view/source/note/new.tpl):
{extends file='layout/app.tpl'}
{block name='content'}
<form method="post" action="{$t_root}note/index">
{if strlen($t_error) > 0}<p class="error">{$t_error}</p>{/if}
<label>Title <input name="title" value="{$t_form_title|default:''}"></label>
<label>Body <textarea name="body">{$t_form_body|default:''}</textarea></label>
<button type="submit">Save</button>
</form>
{/block}
Two cautions worth knowing up front:
actionAction()is invoked for any HTTP method when no method-specific REST handler exists. If you write acreateAction()reachable atGET /note/create, the action runs on GET too — guard with$this->method !== 'POST'before performing any side effect, or use theindexAction+ internal dispatch shape shown above so write logic lives next to a 'POST' branch.- CSRF checking is only enforced by the framework for REST mode (
indexPostRest, etc.) when the user is logged in. HTML actions reached asactionActionskip the framework's CSRF gate by design — for authenticated state-changing forms see "Protect an Authenticated Form" below.
Protect an Authenticated Form¶
When a server-rendered form submits state-changing data behind a login (creating a note, updating an account, posting a comment), you need an explicit CSRF check. The framework provides two helpers on ControllerBase so the controller side is one line each:
| Helper | Use in | What it does |
|---|---|---|
$this->csrfToken() |
controller (GET form action) | returns the session's CSRF token to embed in a hidden field |
$this->requireCsrfFromPost() |
controller (POST handler) | reads csrf_token from $_POST, verifies it, and on failure terminates the dispatch with a 403 — REST callers get the CSRF-TOKEN-INVALID JSON envelope, HTML callers get the csrf.html page |
Skeleton:
class PrivateNoteController extends ControllerBase
{
public function newAction(): void
{
$this->setTitle('New note');
$this->VIEW->setString('t_csrf_token', $this->csrfToken());
}
public function indexAction(): void
{
if ($this->method === 'POST') {
$this->requireCsrfFromPost();
// ... do the protected write, then redirect ...
return;
}
// ... GET: render the list, including a fresh csrf_token for any inline form ...
$this->VIEW->setString('t_csrf_token', $this->csrfToken());
}
}
Form template emits the hidden field:
<form method="post" action="{$t_root}privatenote/index">
<input type="hidden" name="csrf_token" value="{$t_csrf_token}">
<label>Title <input name="title"></label>
<label>Body <textarea name="body"></textarea></label>
<button type="submit">Save</button>
</form>
The default field name is csrf_token. To use a different name, pass it to both sides: $this->requireCsrfFromPost('my_token') and <input type="hidden" name="my_token" value="...">.
Three notes:
- The session token comes from
AuthSession::csrfToken(). It is created at login and remains stable for the lifetime of the session, so the samecsrfToken()value works across all forms in the same login. After logout (or session expiry) the token is regenerated on the next login and old hidden fields stop validating —requireCsrfFromPost()surfaces that as a403automatically. requireCsrfFromPost()is the recommended shape. There is also a low-levelverifyCsrfFromPost(): boolthat returns the verification result without terminating; reach for it only when the handler needs to recover (for example, re-render the form with field-level errors) instead of returning a flat 403 page.- The helpers do not change REST behavior. REST handlers (
indexPostRestetc.) still go through the automatic framework gate that validates theX-CSRF-Tokenheader.
Add a REST Endpoint¶
Use method-specific REST handlers for JSON endpoints. Avoid new {action}Rest() handlers unless a compatibility reason is documented, because they accept every HTTP method through the legacy fallback.
Example goal:
Controller shape:
<?php
declare(strict_types=1);
namespace Nene\Controller;
use Nene\Database as Database;
use Nene\Xion\ControllerBase;
use Nene\Xion\TransactionManager;
class ArticleController extends ControllerBase
{
public function indexGetRest(): array
{
$mapper = new Database\ArticleMapper();
return $this->API_RESPONSE->success([
'articles' => $mapper->findPublishedRows(),
]);
}
public function indexPostRest(): array
{
$title = trim((string)($this->REQUEST_JSON['title'] ?? ''));
if ($title === '') {
return $this->API_RESPONSE->failure('ARTICLE-TITLE-REQUIRED');
}
$mapper = new Database\ArticleMapper();
$transaction = new TransactionManager();
return $this->API_RESPONSE->success([
'article' => $transaction->run(function () use ($mapper, $title): array {
return $mapper->create($title);
}),
]);
}
public function itemGetRest(): array
{
$id = $this->request->getParam('id');
if ($id === null || !ctype_digit((string)$id)) {
return $this->API_RESPONSE->failure('ARTICLE-ID-REQUIRED');
}
$mapper = new Database\ArticleMapper();
$article = $mapper->findRowById((int)$id);
if ($article === null) {
return $this->API_RESPONSE->failure('ARTICLE-NOT-FOUND');
}
return $this->API_RESPONSE->success([
'article' => $article,
]);
}
}
Routing examples:
GET /article/index -> ArticleController::indexGetRest()
POST /article/index -> ArticleController::indexPostRest()
GET /article/item/id_1 -> ArticleController::itemGetRest()
State-changing REST requests such as POST, PUT, PATCH, and DELETE require a valid login session and X-CSRF-Token when the user is logged in. /session/login returns the token as Data.csrfToken.
Normalize the row before returning¶
Mappers return raw PDOStatement::fetch(PDO::FETCH_ASSOC) rows. Every value is a string (or null) — PDO does not type-cast columns by default. Returning a raw row to JSON ships "id": "1", "is_completed": "0", and other loose types.
Define a small per-controller normalizeRow() helper that casts each column to its intended PHP type, and use it on every row the controller returns:
class ArticleController extends ControllerBase
{
public function itemGetRest(): array
{
// ... resolve $id, fetch the row ...
return $this->API_RESPONSE->success([
'article' => $this->normalizeRow($article),
]);
}
public function indexGetRest(): array
{
$mapper = new Database\ArticleMapper();
return $this->API_RESPONSE->success([
'articles' => array_map([$this, 'normalizeRow'], $mapper->findPublishedRows()),
]);
}
private function normalizeRow(array $row): array
{
return [
'id' => (int)$row['id'],
'title' => (string)$row['title'],
'is_published' => (bool)$row['is_published'],
'created_at' => (string)$row['created_at'],
];
}
}
The helper is intentionally per-entity — each table has its own column list and cast intent. TodoController::normalizeRow() in the bundled sample app is the canonical example (is_completed cast to bool, IDs to int).
Two reasons to do this even on a one-off endpoint:
- JSON consumers (browsers, JS clients, the contract test) rely on declared types.
"is_published": "0"evaluates truthy in JavaScript. - The OpenAPI schema you author (
type: integer,type: boolean) does not reshape the response — it only describes the intended shape. The contract test asserts the wire data conforms.
Keep Controllers Thin¶
Small read-only or single-write endpoints may call a mapper directly from the controller. Move logic into a service/use-case class when a controller method starts to coordinate business decisions, multiple mappers, multiple SQL statements, or a transaction.
Use the existing Nene\Model\ namespace under class/model/ for application service classes. This keeps the current Composer autoload shape and avoids adding a second dispatcher path.
Controller example:
<?php
declare(strict_types=1);
namespace Nene\Controller;
use Nene\Model\ArticleService;
use Nene\Xion\ControllerBase;
class ArticleController extends ControllerBase
{
public function indexPostRest(): array
{
$title = trim((string)($this->REQUEST_JSON['title'] ?? ''));
$body = trim((string)($this->REQUEST_JSON['body'] ?? ''));
$service = new ArticleService();
$result = $service->createArticle($title, $body);
if (!$result['ok']) {
return $this->API_RESPONSE->failure($result['errorCode']);
}
return $this->API_RESPONSE->success([
'article' => $result['article'],
]);
}
}
Service example:
<?php
declare(strict_types=1);
namespace Nene\Model;
use Nene\Database as Database;
use Nene\Xion\TransactionManager;
class ArticleService
{
public function createArticle(string $title, string $body): array
{
if ($title === '') {
return ['ok' => false, 'errorCode' => 'ARTICLE-TITLE-REQUIRED'];
}
$mapper = new Database\ArticleMapper();
$transaction = new TransactionManager();
return [
'ok' => true,
'article' => $transaction->run(function () use ($mapper, $title, $body): array {
return $mapper->create($title, $body);
}),
];
}
}
The service returns plain application data. The controller decides how to turn that result into a REST response. Mappers still own SQL.
Add Database Transactions¶
Use Nene\Xion\TransactionManager as the standard transaction boundary. This is the canonical pattern for humans and AI agents when one logical operation needs multiple SQL statements, multiple writes, or multiple mappers.
Do not start a transaction inside DataMapperBase::execute(). That method is a single-statement execution boundary. Transactions belong around the use case that needs all writes to succeed or fail together.
Single mapper example:
$mapper = new Database\ArticleMapper();
$transaction = new TransactionManager();
$article = $transaction->run(function () use ($mapper, $title, $body): array {
return $mapper->create($title, $body);
});
Multiple mapper example:
$articleMapper = new Database\ArticleMapper();
$auditMapper = new Database\AuditLogMapper();
$transaction = new TransactionManager();
$article = $transaction->run(function () use ($articleMapper, $auditMapper, $title, $body): array {
$article = $articleMapper->create($title, $body);
$auditMapper->record('article.created', (int)$article['id']);
return $article;
});
TransactionManager::run() commits when the callback returns and rolls back when the callback throws. If another transaction is already active, the outer boundary remains responsible for the final commit or rollback.
Add Error Codes¶
Controllers should reference stable error codes. Messages and HTTP status values live in config/error_codes.php.
return [
'ARTICLE-ID-REQUIRED' => [
'message' => 'Article id is required.',
'httpStatus' => 400,
],
'ARTICLE-NOT-FOUND' => [
'message' => 'Article was not found.',
'httpStatus' => 404,
],
'ARTICLE-TITLE-REQUIRED' => [
'message' => 'Article title is required.',
'httpStatus' => 400,
],
];
ApiResponse::failure() sets the HTTP status from this catalog and returns the shared failure payload:
The public JSON envelope wraps that data under Data.
Also update
docs/development/error-codes.md. A unit test (ErrorCodeTest::testEveryRuntimeCodeAppearsInDocsMarkdownTable) verifies that every entry inconfig/error_codes.phpappears in the markdown catalog table. Adding to the PHP file without updating the markdown causes unit tests to fail. Add a row for each new code in the table indocs/development/error-codes.md.
Add Database Code¶
Use a data model for schema metadata and validation, and a mapper for SQL.
Model example:
<?php
declare(strict_types=1);
namespace Nene\Database;
use Nene\Xion\DataModelBase;
class Article extends DataModelBase
{
protected static $schema = [
'id' => parent::INTEGER,
'created_at' => parent::DATETIME,
'updated_at' => parent::DATETIME,
'title' => parent::STRING,
'body' => parent::STRING,
'is_deleted' => parent::BOOLEAN,
];
protected static $validation = [
'title' => ['required' => true, 'maxlength' => 255],
'body' => ['required' => true],
'is_deleted' => ['required' => true, 'bool' => true],
];
}
Mapper example:
<?php
declare(strict_types=1);
namespace Nene\Database;
use Nene\Xion\DataMapperBase;
use PDO;
class ArticleMapper extends DataMapperBase
{
protected const MODEL_CLASS = 'Nene\Database\Article';
protected const TARGET_TABLE = 'articles';
protected const KEY_SID = 'id';
public function findPublishedRows(): array
{
$stmt = $this->DB->prepare('
SELECT id, title, body, created_at, updated_at
FROM ' . static::TARGET_TABLE . '
WHERE is_deleted = 0
ORDER BY id DESC
');
return $this->execute($stmt)->fetchAll(PDO::FETCH_ASSOC);
}
public function findRowById(int $id): ?array
{
$stmt = $this->DB->prepare('
SELECT id, title, body, created_at, updated_at
FROM ' . static::TARGET_TABLE . '
WHERE id = :id
AND is_deleted = 0
LIMIT 1
');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$row = $this->execute($stmt)->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
public function create(string $title, string $body = ''): array
{
$stmt = $this->DB->prepare('
INSERT INTO ' . static::TARGET_TABLE . ' (
title,
body,
is_deleted
) VALUES (
:title,
:body,
0
)
');
$stmt->bindValue(':title', $title, PDO::PARAM_STR);
$stmt->bindValue(':body', $body, PDO::PARAM_STR);
$this->execute($stmt);
$row = $this->findRowById((int)$this->DB->lastInsertId());
if ($row === null) {
throw new \RuntimeException('Created article row could not be loaded.');
}
return $row;
}
}
When you add a new table, keep MySQL and SQLite setup aligned:
Reserved method names in DataMapperBase¶
DataMapperBase already declares insert(), update(), delete(), find(), findALL(), countById(), countAll() as public methods. Overriding any of these with an incompatible signature causes a PHP fatal error.
Name your own mapper methods to avoid collisions. For soft-delete (setting is_deleted = 1) the conventional choice is softDelete(int $id): bool.
Column nullability¶
To allow a column to store NULL, add 'nullable' => true to its definition in SchemaDefinition::tables():
'note' => ['type' => 'text', 'nullable' => true],
'parent_id' => ['type' => 'bigint', 'nullable' => true],
Without nullable, every column compiles to NOT NULL. The nullable option applies to text, varchar:*, and bigint columns. pk-bigint, bool, and datetime-* columns are always NOT NULL and ignore the option.
Add Authentication Requirements¶
By default, controllers require a valid session. Set $this->SESSION_CHECK = false in preAction() only for public pages or public endpoints.
Authentication-related statuses:
SESSION-CLOSED:401, no valid login session.LOGIN-FAILED:401, submitted credentials were rejected.CSRF-TOKEN-INVALID:403, session exists but the CSRF token is missing or invalid.
For state-changing REST endpoints:
- Login with
POST /session/login. - Read
Data.csrfToken. - Send
X-CSRF-TokenwithPOST,PUT,PATCH, orDELETE.
External clients (curl, fetch, custom SDK) also need to manage the PHPSESSID cookie returned by login. docs/api/reference-client.md covers the full mechanics with runnable examples.
Add an HTML Login Form¶
The bundled SessionController is REST-only — it handles POST /session/login as JSON in / JSON out. For a server-rendered application with a user-facing login page, create your own controller with HTML actions and reuse the framework's AuthSession directly. The login() accepts the row returned by UserMapper::findByCredentials($userId, $userPass), so the controller stays short:
<?php
declare(strict_types=1);
namespace Nene\Controller;
use Nene\Database;
use Nene\Xion\ControllerBase;
class AuthController extends ControllerBase
{
protected function preAction(): void
{
$this->SESSION_CHECK = false; // the login page must be reachable while unauthenticated
}
public function loginAction(): void
{
if ($this->method === 'POST') {
$this->handleLoginPost();
return;
}
$this->setTitle('Sign in');
$this->VIEW
->setString('t_error', '')
->setString('t_form_user_id', '');
}
public function logoutAction(): void
{
if ($this->method !== 'POST') {
// Defense: a side-effect action should never run on GET.
$this->location('/auth/login');
return;
}
$this->requireCsrfFromPost();
$this->AUTH_SESSION->logout(true);
$this->location('/auth/login');
}
private function handleLoginPost(): void
{
$userId = trim((string)($this->request->getPost('user_id') ?? ''));
$userPass = (string)($this->request->getPost('user_pass') ?? '');
if ($userId === '' || $userPass === '') {
$this->renderLoginError($userId, 'Enter both user id and password.');
return;
}
$user = (new Database\UserMapper())->findByCredentials($userId, $userPass);
if ($user === null) {
$this->renderLoginError($userId, 'Wrong user id or password.');
return;
}
$this->AUTH_SESSION->login($user);
$this->location('/dashboard');
}
private function renderLoginError(string $userId, string $error): void
{
$this->setTitle('Sign in');
$this->VIEW
->setString('t_error', $error)
->setString('t_form_user_id', $userId)
->setTemplate('auth/login.tpl');
}
}
A few framework boundaries are worth pointing out:
$this->SESSION_CHECK = falseinpreAction()keeps the login page itself unauthenticated. Without it,sessionCheck()would redirect anonymous visitors away from the login form they need to fill in.- The login handler does not verify a CSRF token — the user has no session yet, so there is nothing to bind a token to. Logout and any subsequent protected forms do require CSRF; see "Protect an Authenticated Form" above.
$this->AUTH_SESSION->login($user)callssession_regenerate_id(true)internally. Browsers handle the twoSet-Cookie: PHPSESSID=...headers correctly; hand-written clients should followdocs/api/reference-client.md.- After login, redirect to a protected page (
/dashboardhere) via$this->location(). The framework'ssessionCheck()will redirect unauthenticated visitors toLOGOUT_URI(setNENE_LOGOUT_URI=/auth/loginso they land on this form, or overrideunauthorizedRedirect()per controller).
Matching login form template (view/source/auth/login.tpl):
{extends file='layout/app.tpl'}
{block name='content'}
<form method="post" action="{$t_root}auth/login">
{if strlen($t_error) > 0}<p class="error">{$t_error}</p>{/if}
<label>User ID <input name="user_id" value="{$t_form_user_id|default:''}" autocomplete="username" required></label>
<label>Password <input type="password" name="user_pass" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
</form>
{/block}
A logout button on a protected page is just a one-line form that posts the CSRF token to logoutAction():
<form method="post" action="{$t_root}auth/logout" style="display:inline">
<input type="hidden" name="csrf_token" value="{$t_csrf_token}">
<button type="submit">Sign out</button>
</form>
Update OpenAPI¶
Every public REST endpoint should be described in:
For a new endpoint, add:
- Path and HTTP method.
- Request body schema, if any.
- Success response schema.
- Failure responses such as
400,401,403,404, and405. - Any security requirements, including
sessionCookieandcsrfToken.
Swagger UI is available locally at:
File upload (multipart) operations¶
For endpoints that accept a multipart/form-data request body, use:
/attachment/index:
post:
tags: [Attachment]
summary: Upload an attachment for the signed-in user
operationId: createAttachment
security:
- sessionCookie: []
csrfToken: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
"200":
description: Upload accepted.
# ... your success envelope ...
"400":
description: Upload file is missing (`UPLOAD-FILE-REQUIRED`).
content:
application/json:
schema:
$ref: "#/components/schemas/ApiFailureEnvelope"
examples:
uploadRequired:
value:
Result: true
Data:
status: failure
errorCode: UPLOAD-FILE-REQUIRED
errorMessage: Upload file is required.
"401":
$ref: "#/components/responses/SessionClosed"
"403":
$ref: "#/components/responses/CsrfTokenInvalid"
"413":
description: Upload exceeds size limit (`UPLOAD-TOO-LARGE`).
"415":
description: Upload mime type is not allowed (`UPLOAD-MIME-REJECTED`).
The runtime contract test (OpenApiRuntimeContractTest) only reads JSON examples; it probes multipart operations with an empty body. Make sure the documented responses: list includes whatever status your controller returns for the "missing file" case — for the framework's UploadedFile::validate() helper that's 400 UPLOAD-FILE-REQUIRED. If the operation cannot tolerate the empty-body probe (destructive write etc.), add x-nene-runtime-probe: skip.
Send an email¶
Nene\Xion\Mailer (ADR-0006) wraps Symfony Mailer. Send a message from inside any controller:
use Nene\Xion\MailMessage;
use Nene\Xion\Mailer;
class AuthController extends ControllerBase
{
public function passwordResetPostRest(): array
{
// ... resolve $email, generate $token ...
Mailer::getInstance()->send(new MailMessage(
to: $email,
subject: 'Reset your password',
body: "Open the link to reset:\n\nhttps://example.com/auth/reset?token={$token}\n",
));
return $this->API_RESPONSE->success(['queued' => true]);
}
}
Pass contentType: 'text/html' for HTML mail and from: to override the default sender. The transport reads from NENE_MAIL_DSN; the dev stack catches everything in mailpit at http://localhost:8025/ so you never accidentally email a real user.
See docs/development/email-sending.md for the full guide (env matrix, production SMTP, test injection, what is intentionally out of scope).
Add Tests¶
Add the smallest useful test for the behavior.
Use unit tests for pure or boundary-level code:
Use HTTP runtime tests for real routing, sessions, cookies, REST payloads, and OpenAPI status coverage:
Good HTTP test targets:
- Successful login and CRUD flow.
- Authentication failure returns
401. - Validation failure returns a catalog error code.
- Missing records return
404. - Unsupported methods return
405andAllow. - OpenAPI documents observed runtime statuses.
Per-entity CRUD test pattern¶
tests/Http/TodoTest.php is the canonical per-entity CRUD test example. Study it before writing your first HTTP test. The key conventions:
- Prefix test data with
self::TEST_TODO_PREFIX(inherited fromHttpRuntimeTestCase). The basesetUp()deletes any leftover rows with that prefix before each test, preventing cross-test contamination. - Track created rows by calling
$this->createTodo()(or your own helper that appends to$this->cleanupTodoIds). The basetearDown()deletes all tracked rows even when an assertion fails mid-test. - Get an authenticated client with
$this->loginAsAdmin(). TheHttpClientautomatically stores the session cookie and CSRF token after the login response, so subsequentPOST/PUT/DELETEcalls sendX-CSRF-Tokenwithout any extra setup. - Test unauthenticated access by calling
$this->newClient()— it returns a fresh client with no session, simulating an anonymous browser. - One behaviour per test method; keep assertions tight (status code + error code is enough for error cases).
Implementation Checklist¶
Before opening a PR:
- Create or confirm the GitHub Issue.
- Add or update controller methods.
- Add service/use-case code when business logic would make a controller method hard to read.
- Add templates/assets for HTML pages.
- Add mapper/model/schema changes for database-backed features.
- Add error codes to
config/error_codes.php. - Update
docs/api/openapi.yamlfor public REST endpoints. - Add focused unit or HTTP runtime tests.
- Review
docs/ai/self-review/before opening the PR. - Run
composer test. - Run
composer analyze. - Run
NENE_HTTP_BASE_URL=http://localhost:8080 composer test:httpwhen HTTP behavior changes.
Keep each PR focused. A page, an API endpoint, and a schema change can be together when they are one feature, but unrelated cleanup should be separate.