Part 3 — Persistence
Why this step exists: a domain that cannot store data is just a
description. This part gives the application a database and runs it for the
first time. You will not write a migration or a CREATE TABLE statement —
AUSUS derives the schema from the entity you declared.
How persistence works
When Application::boot() runs, it:
- compiles your plugin into a MetadataGraph;
- derives a SQL schema from that graph — one table per entity, plus an
internal
kernel_audit_logtable; - applies the schema to the database (
CREATE TABLE IF NOT EXISTS, so it is safe to run repeatedly).
In v0.1.x the persistence driver is SQLite. You point the Application at
a file path and AUSUS manages the rest. The table for helpdesk.ticket is
named helpdesk_ticket — dots become underscores.
Write the CLI runner
Create tickets.php in the project root. This script bootstraps the
application, creates a few tickets, drives the workflow, and prints the result:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Ausus\Application;
use Helpdesk\TicketSystem;
// Start from a clean database every run so the script is repeatable.
$dbPath = __DIR__ . '/tickets.sqlite';
if (file_exists($dbPath)) {
unlink($dbPath);
}
// Bootstrap: compile the plugin, open the SQLite file, apply the schema.
$app = Application::create([
'tenant' => 'helpdesk',
'roles' => ['ticket.agent', 'ticket.viewer'],
'database' => $dbPath,
])->register(new TicketSystem())->boot();
echo "AUSUS Ticket System — CLI run\n";
echo "database: {$dbPath}\n\n";
/** Create one ticket and return its id. */
function newTicket(Application $app, string $title, string $requester, string $priority): string
{
$result = $app->invoke('helpdesk.ticket.create', null, [
'title' => $title,
'requester' => $requester,
'priority' => $priority,
]);
echo " created {$result['id']} [{$priority}] {$title}\n";
return $result['id'];
}
echo "creating tickets:\n";
$a = newTicket($app, 'Printer on floor 3 is offline', 'dana@example.com', 'HIGH');
$b = newTicket($app, 'VPN drops every hour', 'sam@example.com', 'NORMAL');
$c = newTicket($app, 'Request a second monitor', 'lee@example.com', 'LOW');
// Drive the workflow. invoke() returns the updated fields.
echo "\ndriving the workflow:\n";
$app->invoke('helpdesk.ticket.start', $app->reference('helpdesk.ticket', $b));
echo " ticket {$b} → IN_PROGRESS\n";
$app->invoke('helpdesk.ticket.start', $app->reference('helpdesk.ticket', $c));
$resolved = $app->invoke('helpdesk.ticket.resolve', $app->reference('helpdesk.ticket', $c));
echo " ticket {$c} → RESOLVED at {$resolved['resolved_at']}\n";
// The workflow guard rejects an illegal transition.
echo "\nworkflow guard:\n";
try {
$app->invoke('helpdesk.ticket.resolve', $app->reference('helpdesk.ticket', $a));
} catch (\Ausus\WorkflowStateMismatch $e) {
echo " rejected resolve on an OPEN ticket — as expected\n";
}
// Render the summary projection — the same data the HTTP API will serve.
$view = $app->render('helpdesk.ticket.summary');
$count = count($view['data']['items']);
echo "\nsummary projection lists {$count} tickets.\n";
echo "done.\n";
Run it
php tickets.php
Expected output:
AUSUS Ticket System — CLI run
database: /…/ticket-system/tickets.sqlite
creating tickets:
created 01J… [HIGH] Printer on floor 3 is offline
created 01J… [NORMAL] VPN drops every hour
created 01J… [LOW] Request a second monitor
driving the workflow:
ticket 01J… → IN_PROGRESS
ticket 01J… → RESOLVED at 2026-05-21T…Z
workflow guard:
rejected resolve on an OPEN ticket — as expected
summary projection lists 3 tickets.
done.
The 01J… values are ULIDs — the identity
AUSUS generates for every record. Yours will differ.
:::tip Why the guard rejection is a success
resolve is declared from: 'IN_PROGRESS'. Ticket a is still OPEN, so
there is no matching transition and the runtime throws WorkflowStateMismatch
before touching the database. That rejection is the workflow doing its job.
:::
Look inside the database
AUSUS created a real SQLite file. If you have the sqlite3 CLI, inspect it:
sqlite3 tickets.sqlite '.tables'
helpdesk_ticket kernel_audit_log
sqlite3 tickets.sqlite 'SELECT id, title, priority, status FROM helpdesk_ticket;'
01J…|Printer on floor 3 is offline|HIGH|OPEN
01J…|VPN drops every hour|NORMAL|IN_PROGRESS
01J…|Request a second monitor|LOW|RESOLVED
Two things worth noticing, and why:
helpdesk_ticket— you never wrote this table.SchemaDeriverbuilt it from the entity, including the system columnsid,tenant_id,_version,created_at,updated_at.kernel_audit_log— every action you invoked appended an entry here. Auditing is automatic; the runtime writes it inside the same transaction as the change.
What you have now
ticket-system/
├── composer.json
├── src/TicketSystem.php
├── tickets.php ← CLI runner
├── tickets.sqlite ← created at runtime, holds 3 tickets
└── vendor/
The application works end to end on the command line. Next, you put it behind HTTP so other programs — including a browser — can reach it.
Next: Part 4 — HTTP API.