Audit CLI
StaticPHP\Utils\Models\Audit\Cli is the staticphp audit command. This package provides
the class; the staticphp executable that dispatches to it ships with the
application skeleton, and the map that connects
the two is StaticPHP\Core\Cli - see
the command registry.
Like migrate and i18n it is reached before src/Core/Helpers/Bootstrap.php runs and
never touches the router. Routing configuration has no notion of a cli-only route, so a
controller would also answer over http - here on a tool that writes schema and deletes
history.
The verbs are install and prune. There are no others.
Usage: staticphp audit <command> [options]
Commands: install Write the audit schema into the migrations directory prune Delete trail rows older than a date
Options: --before=DATE prune: delete rows older than this, YYYY-MM-DD --batch=N prune: rows per statement (default: 10000) --dry-run prune: count what would go, delete nothing --dir=PATH install: migrations directory to write into --table=NAME Audit table (default: from config) --connection=NAME Entry of config['db']['pdo'] to use (default: from config) --project=NAME Application under src/ to load config from (default: Application) --help This textThat text is Cli::USAGE. It is printed by --help - and by -h, which the text does not
mention - anywhere in the arguments, by an empty command line, and by an unknown command.
--help exits 0; the other two exit 2.
Options are order-insensitive and may appear before or after the verb. --dry-run is the only
flag; the rest take a value in --name=value form. Anything else is a hard error before a
connection is opened:
error: unknown option --forceerror: --before needs a value, as --before=somethingBoth go to stderr and exit 2. So does an unrecognised verb, which prints the usage text after the error:
error: unknown command "bogus"install
Section titled “install”staticphp audit installstaticphp audit install --table=history_catalogstaticphp audit install --dir=/srv/app/src/Application/MigrationsCopies the schema for the connection’s driver into the application’s migrations directory, as an ordinary migration:
Wrote /srv/app/src/Application/Migrations/2026-08-02-010330-create-audit-log.sqlReview it, then: staticphp migrate applyapplied 2026-08-02-010330-create-audit-log.sql (0 ms)Applied 1 migration(s).Core does not ship a migration of its own and does not create the table at runtime, which is a
deliberate choice rather than an omission.
Discovery globs one directory and sorts by filename,
and Tracker checksums every file it applied, so a framework-owned migration would sort by
the day the package was released and turn every composer update into checksum drift on a
file the application cannot edit. Generating one hands it over instead: it is the
application’s file, in the application’s timeline, and it can be edited before it is applied.
The filename comes from Discovery::newFilename() rather than being spelled here, so it is
guaranteed to satisfy the pattern
migrate globs for -
an underscore in the table name would otherwise produce a file that tool refuses to read.
Renaming the table
Section titled “Renaming the table”--table=NAME rewrites every occurrence of audit_log in the template, which renames the
indexes with it, so two trails can coexist in one schema. That is also why the name is
validated before the rewrite.
A deployment whose $config['audit']['table'] is a resolver cannot be asked which table to
install - the resolver answers per event - so it has to be named on the command line, one run
per table:
error: config['audit']['table'] is a resolver, so it names a different table per event.Name the one to work on with --table=NAME.install failures
Section titled “install failures”error: no migrations directory at /srv/app/nopeAn unsupported driver is the other exit 2:
error: no install template for oracleAn existing target file, an unreadable template and a failed write exit 1. Two runs that do land in the same second show the guard working:
Wrote /tmp/cap_install_0e33657f/2026-08-03-000000-create-audit-log.sqlReview it, then: staticphp migrate applyerror: /tmp/cap_install_0e33657f/2026-08-03-000000-create-audit-log.sql already existsstaticphp audit prune --before=2026-01-01staticphp audit prune --before=2026-01-01 --dry-runstaticphp audit prune --before="2026-01-01 03:00:00" --batch=5000Deletes trail rows whose created_at is strictly before the given date. In batches,
because a single unbounded DELETE against the biggest table in the database is how a
retention job becomes an outage.
--dry-run counts and reports and changes nothing:
4 rows in audit_log older than 2026-01-01
Nothing deleted (--dry-run).Without it, with a deliberately small batch so the loop runs more than once:
4 rows in audit_log older than 2026-01-01Deleted 2/4Deleted 4/4Done. 4 rows removed.Nothing to delete is a success, and prints only the count:
0 rows in audit_log older than 2026-01-01--before is required and is matched against ^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$ - a
date, or a date and a time, and nothing else. There is no relative form:
error: prune needs --before=YYYY-MM-DDerror: --before must be YYYY-MM-DD or "YYYY-MM-DD HH:MM:SS", got "yesterday"--batch defaults to 10000 and must be at least 1. A value that is not numeric becomes 0 and
fails the same check:
error: --batch must be at least 1All three exit 2. A table that cannot be read exits 1 with
error: cannot read audit_log: <the database error> rather than a stack trace.
The delete statement
Section titled “The delete statement”DELETE FROM audit_log WHERE id IN ( SELECT id FROM (SELECT id FROM audit_log WHERE created_at < ? ORDER BY id LIMIT 10000) AS batch)The inner select is wrapped a second time because mysql refuses to read the table it is deleting from in a subquery, and a derived table is the way round it that the other two do not mind. The loop repeats until it has removed the number of rows the initial count reported, and stops early if a statement removes none - so it cannot spin on a batch it is unable to delete.
--before is bound; the table name and the batch size are concatenated, which is what
assertTableName() and the is_numeric() cast are there for.
Partitioned tables
Section titled “Partitioned tables”Before deleting anything, prune asks postgres whether the table is a partitioned parent -
only postgres, because it is the only one of the three where the answer changes the advice -
and if it is, prints how to do it properly instead:
<table> is partitioned. Dropping whole partitions is instant wherethis delete is not: ALTER TABLE <table> DETACH PARTITION <name>; DROP TABLE <name>;It still performs the delete afterwards.
Where the settings come from
Section titled “Where the settings come from”| Setting | Option | Config key | Default |
|---|---|---|---|
| Connection | --connection=NAME |
audit.connection |
default |
| Table | --table=NAME |
audit.table |
audit_log |
Directory (install) |
--dir=PATH |
migrations.dir |
APP_PATH . '/Migrations' |
| Project | --project=NAME |
- | Application |
The directory comes from $config['migrations'], not from $config['audit'] - the file it
writes is a migration, and it belongs where the rest of them are. A table config value of
null resolves to audit_log; a callable is refused unless --table overrides it.
Before any of that, Cli does the part of the bootstrap a schema tool needs and none of the
part it does not - no router, no session, no view engine. It checks that
{$basePath}/src/{$project}/Public exists, defines PUBLIC_PATH as that directory, registers
the autoloader, loads Config, then everything listed in $config['autoload_configs'].
Afterwards it checks three keys independently, and loads this package’s own config file for
each one the application left empty: $config['db']['pdo'] from Utils/Config/Db.php,
$config['audit'] from Utils/Config/Audit.php and $config['migrations'] from
Utils/Config/Migrations.php.
--project=NAME selects which application under src/ to boot, and a name with no Public
directory exits 2 before anything is loaded:
error: project "Nope" not found at /srv/app/src/Nope/PublicAn unknown connection name is reported with the ones that do exist:
error: no database connection "reporting" in config['db']['pdo'] (configured: default)See running several applications.
Unlike migrate, this command does not force persistent to false on the connection it
opens. It takes no locks, so there is nothing for a pooled connection to strand.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
| 0 | Success, including --help and a prune that found nothing to delete |
| 1 | The command refused or failed: a target file that exists, an unreadable template, a failed write, a connection that could not be opened, a table that could not be read, or not running under the cli SAPI |
| 2 | The invocation was wrong: unknown verb or option, missing option value, missing --before, a malformed date, a batch below 1, a missing migrations directory, an unsupported driver, an unresolvable table, an unknown project or an unknown connection |
Cli’s own diagnostics go to stderr; everything Commands prints - including its
error: lines - goes to stdout, because Commands writes through an injected callable so
that the whole surface is testable without capturing output buffers. A shell that redirects
only one of the two will miss half the errors.
There is no catch-all, so an unexpected exception is a php fatal with exit 255 rather than a
tidy error: line. See the caution above.
In a cron job
Section titled “In a cron job”staticphp audit prune --before="$(date -u -d '2 years ago' +%Y-%m-%d)" --batch=5000Run it as a role that has DELETE on the table while the application does not; see
operational notes. Unlike
migrate apply, this command takes no lock,
so nothing serialises two runs that overlap.