Framework
Introduction

CLI Commands

Everything is driven by the ./framework binary (an alias for ./vendor/bin/framework). Every command is a #[ConsoleCommand] discovered across the framework — and your own classes can expose commands the same way.

Commands

The built-in commands cover the whole workflow — installing, building, migrating and more:

version

Prints the installed framework version. Also available as the -v shorthand.

./framework version
./framework -v

install

Scaffolds a new project: copies the framework binary, the sample config and the entry files into your app and wires everything up. Run it once, right after requiring the package.

./framework install

build

Runs discovery and regenerates the typed code under src/System — config, routes, access roles, settings and schemas. Run it after changing anything the generator reads.

./framework build

destroy

Removes the generated code that build produced, leaving your own source untouched. Handy before a clean rebuild or when switching branches.

./framework destroy

watch

Watches your app source and rebuilds automatically whenever a file changes — the convenient way to develop. It respects .gitignore and runs until you stop it.

./framework watch

migrate

Applies every pending migration: syncs the schema to your models, runs the table and column renames and executes the data migrations. Pass an env file to force a specific environment.

./framework migrate
./framework migrate .env.production   # force an environment

postDeploy

Runs the post deploy step of the migrations that already ran, once the new code is live, so what the old code wrote in between is migrated too. Pass an env file the same way as migrate.

./framework postDeploy
./framework postDeploy .env.production   # force an environment

migration

Scaffolds a new data migration file, prompting for a title (or taking it as an argument) and opening it for editing. Files are grouped in folders by year and month.

./framework migration
./framework migration "Backfill user slugs"

ensurePaths

Creates the file-storage directories — the built-in temp, source, thumbs and avatars folders plus any you registered — so uploads have somewhere to live.

./framework ensurePaths

icons

Generates the icon stylesheet and a preview page from your registered icon sets. Point it at your icons with Icons::setSource() and Icons::register() first.

./framework icons

nlsCheck

Compares the language files of each directory against each other — the keys they hold, the keys inside those, and the line or the position each one is at — and fails when they have drifted apart. It reads the strings, the emails and the notifications, plus the directories of your own apps, whose files are written as a script.

./framework nlsCheck

With the source directories configured it also reports the keys nothing uses, and the ones the code asks for that no file defines.

See checking the files for what each directory is compared by, how the report reads, and how to register an app directory.

libraries

If you need to download a project from git and place the files in some location of your project, you can use the following commands:

./framework libraries
./framework library --name=dashboard
./framework library

./framework libraries places every project your composer file lists, which is what a deploy runs.

./framework library places one of them, named with --name. Leave the name out and it lists the projects and asks which one, so you can answer with the number or the name.

Both download the archive of the tag or the branch, empty the path and copy the source into it. They do this on every run and never check what is already there, so a project you edited in place goes back to what the tag holds.

The projects to download go under the extra.libraries key of your composer file, one entry each:

"extra": {
    "libraries": {
        "dashboard": {
            "url"    : "https://github.com/FrameworkDevAR/Dashboard",
            "tag"    : "v0.1.0",
            "source" : "src",
            "path"   : "client/src/Dashboard"
        }
    }
}

The name of the entry is what you pass to --name. Each one takes these keys:

KeyWhat it is
urlThe repository to download from.
tagThe git tag to download, written exactly as the repository names it. A tag named v0.1.0 is written v0.1.0.
branchThe git branch to download, such as main. Only read when there is no tag.
sourceThe directory of the repository to copy. Leave it out to copy all of it.
pathWhere the command copies that directory. The path starts at the directory above the one your composer file is in, so a server in server/ reaches a client in client/.
Keep nothing of your own at one of these paths, since the command empties it on every run. On a deploy the order is composer install, then ./framework libraries, then ./framework build.

Adding your own commands

Any public static method in your app can become a command: tag it with #[ConsoleCommand] and it is discovered and wired to the CLI on the next build:

use Framework\Discovery\Attr\ConsoleCommand;

class Reports {
    #[ConsoleCommand("report", "-r")]
    public static function generate(string $month = "", bool $email = false): void {
        print("Generating the report...\n");
        // …your logic here
    }
}
./framework report --month=2026-08 --email
./framework -r --month=2026-08

How the arguments work

The method's parameters are the arguments — there is nothing to declare twice. Each is matched by name, so order on the command line does not matter:

ParameterPassed as
string $month--month=2026-08
int $limit--limit=50 — a numeric value becomes an int.
bool $email--email on its own, or --email=true / --email=false.

Names are matched case-insensitively and the -- is optional, so all of these reach the same parameter:

./framework report --month=2026-08
./framework report month=2026-08
./framework report --MONTH=2026-08

A parameter with a default is optional. One without a default is required — if it is missing the command does not run, and the CLI prints the usage line it built from the signature:

Invalid arguments
  Usage: report (-r) --month=<value> --email

That usage line is generated, so it always matches the method. It is also why a bool shows as a bare flag while everything else shows =<value>.

Order & aliases

The second argument of the attribute is an alias, for the commands you type often — version answers to -v. Commands are listed in Priority order, which is why version and install appear at the top rather than wherever the scan happened to find them:

#[ConsoleCommand("report", "-r")]
#[Priority(Priority::High)]
public static function generate(): void {}

Asking the user

A command can ask for what it was not given. Console reads the answer from the terminal:

CallWhat it does
Console::prompt($text)Asks for a line and returns what was typed.
Console::confirm($text)Asks the same with a (y/n), and returns whether the answer was yes.
Console::choose($text, $options)Lists the options numbered, then asks. The answer can be the number or the name, and what comes back is the option as the list spells it, or an empty string when the answer is neither.
use Framework\Console;
use Framework\Discovery\Attr\ConsoleCommand;

class Reports {
    #[ConsoleCommand("report")]
    public static function generate(string $month = ""): void {
        if ($month === "") {
            $month = Console::choose("Which month", [ "2026-07", "2026-08" ]);
        }
        if ($month === "" || !Console::confirm("Send it when it is done")) {
            return;
        }
        // …your logic here
    }
}
$ ./framework report
  1. 2026-07
  2. 2026-08
Which month: 2
Send it when it is done (y/n): y

Give the argument on the command line and nothing is asked, which is what a script or a scheduled run needs.

Running commands on a schedule

A command is also how scheduled work runs. There is no built-in scheduler — you expose the work as a command and let the server's cron call it:

class EmailCommands {
    #[ConsoleCommand("sendEmails")]
    public static function send(): void {
        EmailQueue::sendAll();
    }
}
# crontab
* * * * *   cd /srv/app && ./framework sendEmails
0 3 * * *   cd /srv/app && ./framework cleanLogs

Anything running this way has no request behind it — no route, no signed-in user — so it has to be told the language explicitly, and reads its configuration from the ENV_FILENAME you point it at. The email and notification queues, and the log cleanup, all work this way.