Search by

preflow / devtools

smyr

Preflow devtools — CLI commands, dev inspector

Package info

github.com/getpreflow/devtools

pkg:composer/preflow/devtools

Statistics

Installs: 824

Dependents: 11

Suggesters: 6

Stars: 0

Open Issues: 0

v0.76.0 2026-11-13 16:17 UTC

README

CLI commands for Preflow development. Requires the preflow entry point in your project root (provided by preflow/starter and preflow/starter-folio) or the package binary at vendor/bin/preflow.

Installation

composer require preflow/devtools --dev

Commands

serve — start the dev server

Starts PHP's built-in server rooted at public/.

php preflow serve
# Preflow dev server started on http://localhost:8080
# Press Ctrl+C to stop.

php preflow serve 0.0.0.0 9000
# Binds to 0.0.0.0:9000

migrate — run pending migrations and post-migrate steps

Boots the application (so packages register their migration paths and steps), then:

  1. runs every pending file from migrations/ and from the paths packages registered (core's preflow_throttle table among them) on a SQL storage;
  2. runs every registered post-migrate step, such as Folio's table sync for SQL-stored content types.
php preflow migrate
# 2 pending migration(s).
# [folio.tables] created table post
# Done.
php preflow migrate --pretend          # list the pending migrations, change nothing
php preflow migrate --storage=sqlite   # the storage to migrate (default: sqlite)
  • Steps run on every run, not only when a migration was pending, so they must be idempotent. Folio's step is SQLite-only for now (a Folio type on MySQL makes it throw), and creates and adds only; it never drops or renames.
  • --pretend skips the steps. The command exits 1 with Error: ... on stderr when it fails.
  • A site without SQL storage (JSON only, or no config/data.php) prints Nothing to migrate: no SQL storage configured. and exits 0, so a shared deploy step can always run it. An explicit --storage=<key> that is unknown or not SQL still exits 1.

db:seed [--class=<Name>] — run the seeders

Boots the application (like migrate), then runs the classes in app/Seeds/ in filename order. Each seeder is built by the container, so its constructor can take any service, and gets the container's DataManager:

namespace App\Seeds;

use Preflow\Core\Config\Config;
use Preflow\Data\DataManager;

final class DemoSeeder
{
    public function __construct(private readonly Config $config) {}

    public function run(DataManager $data): void
    {
        // A demo seed must never touch production data.
        if (!$this->config->get('app.debug')) {
            echo "Demo data skipped: app.debug is off.\n";
            return;
        }

        $job = new Job();
        $job->uuid = md5('demo-job-1');   // fixed id: rerunning updates, never duplicates
        $job->title = 'Barista';
        $data->save($job);
    }
}
php preflow db:seed
# Running seeders...
#   Seeded: DemoSeeder
# Done.
php preflow db:seed --class=DemoSeeder      # one seeder; a short name under App\Seeds, or fully qualified
  • .env is loaded, so a seeder can read $_ENV / getenv().
  • save() validates: the container's DataManager has the validator, so #[Validate] rules apply.
  • An unknown --class prints Seeder <Name> not found. and exits 1; a seeder that throws prints Seeder <Name> failed: <message> and exits 1.
  • A project without app/Seeds/ prints No seeders directory found (app/Seeds/). and exits 0.

Upgrading: seeders used to run on a hand-built SQLite connection, with no container, no validation and no .env. They now run through the booted app, so #[Validate] rules apply and seed data that breaks them now fails. Seeders with a constructor that needs arguments now get them from the container instead of failing.

make:component Name — scaffold a component

Creates app/Components/Name/Name.php and app/Components/Name/Name.twig.

php preflow make:component ProductCard
# Component created: app/Components/ProductCard/
#   - ProductCard.php
#   - ProductCard.twig

The generated PHP class extends Component with empty resolveState() and actions(). The Twig template includes {% apply css %} and {% apply js %} blocks.

make:model Name — scaffold a model

Creates app/Models/Name.php with #[Entity], #[Id], #[Field], and #[Timestamps] attributes. The table name is derived by snake-casing the class name and appending s.

php preflow make:model BlogPost
# Model created: app/Models/BlogPost.php
# (maps to table: blog_posts)

make:model Name --from-type=key — turn a JSON type into a model class

Reads config/models/<key>.json and writes app/Models/Name.php: #[Entity(table, storage, type)], the id, one property per field, with #[Field], #[Relation] and #[Validate] carrying the data parts and labels. The JSON file is rewritten to its admin-only part (presentation: label, icon, list columns, per field readonly, hidden, …), so nothing is defined twice. Same table, same data, same ids: nothing in the database changes.

php preflow make:model Job --from-type=job
# Model created: app/Models/Job.php
# Admin settings kept in: config/models/job.json
# Check the class, then run your tests: nothing in the database changes.
  • Every property is nullable and takes the JSON default; required stays a validation rule, so a stored NULL is not rewritten by a later save. A translatable property is array|string|null; several assets, blocks, a matrix and a multiple relation are ?array; one asset is its id (?string).
  • A relative entry in data.model_paths is under the app's base path.
  • Before writing, the command loads the generated class and compares the type it derives with the type from the JSON file. Any difference is an error and nothing is written.
  • It refuses a class that exists, a key that is already class-backed, a class name PHP reserves (Object, List, …) and a type with matrixable or views, which a class cannot carry yet. Each error names the key.

make:controller Name — scaffold a controller

Creates app/Controllers/NameController.php with a #[Route] class attribute and a #[Get] index action. Appends Controller to the name if not already present.

php preflow make:controller Article
# Controller created: app/Controllers/ArticleController.php
# (route prefix: /api/article)

make:migration name — create a migration file

Creates a timestamped file in migrations/ with empty up() and down() stubs.

php preflow make:migration create_comments
# Migration created: migrations/2026_04_10_143012_create_comments.php

routes:list — print the route table

Boots the app and prints its routes in the order they match: the component endpoint (answered before routing), then file pages, controllers and the routes packages add (media, Folio). What middleware answers (redirects, sitemap, robots.txt, llms.txt) isn't listed. If the app doesn't boot, it lists the file pages only and says why.

php preflow routes:list
# Registered Routes
# ──────────────────────────────────────────────────────────────────────
# Method  Pattern                            Mode        Handler
# ──────────────────────────────────────────────────────────────────────
# ANY     /--component/*                     endpoint    Preflow\Htmx\ComponentEndpoint
# GET     /                                  component   index.twig
# GET     /api/places                        action      App\Controllers\Api\PlacesController@index
# ──────────────────────────────────────────────────────────────────────

cache:list — list registered caches

Shows every cache registered with the core cache registry: key, label, number of entries and size.

php preflow cache:list
# Key        Label                     Entries  Size
# data.json  JSON storage query cache  12       3.1 KB

cache:clear [KEY] — clear framework caches

cache:clear <key> clears one registered cache; cache:clear --all clears every registered cache. An unknown key exits with 1 and lists the known keys. Files outside the registered caches are never deleted. The bare form (cache:clear without arguments) still clears all caches but prints a note; it will require --all in a later release. An unknown option (anything starting with -- other than --all) exits with 1 and clears nothing.

cache:list and cache:clear boot the application to read the registry, so they need what the app needs to boot (.env, config, a reachable database for SQL drivers). When the boot fails they print the error and exit with 1.

php preflow cache:clear --all
# Cleared data.json: 12 file(s), 3.1 KB.

pages:work [--budget=50] — rebuild queued pages (cron)

With the page cache on (preflow/core's README, "Page cache"), writes forget the pages that showed what they changed and queue the full pages among them for rebuilding; stale pages and a deploy's warm-up are queued too. The request that queued them rebuilds for rebuild_budget seconds after its response; pages:work takes the rest, for up to --budget seconds (50 by default). Run it every minute from the cron:

* * * * * cd /path/to/site/current && php preflow pages:work >/dev/null 2>&1

The queue's leases keep it and the after-response work from building one page twice. With the cache off, or app.debug on, it rebuilds nothing.

php preflow pages:work
# Rebuilt 12 page(s) in 1.4 s; 0 queued.

pages:refresh <url> / pages:refresh --all — refresh cached pages

pages:refresh <url> forgets every cached copy of one URL (each locale and htmx fragment variant; a path such as /about is taken against app.url) and rebuilds the page. pages:refresh --all forgets every page and queues every known full page without a query. Both then rebuild for up to --budget seconds (50) and leave the rest to pages:work. A URL of a host the cache doesn't cache exits with 1. Folio's Settings → Cache has the same two actions.

php preflow pages:refresh /about
# Forgot 1 cached copy of /about.
# Rebuilt 1 page(s); 0 queued.
php preflow pages:refresh --all
# Forgot every page; 240 queued for rebuilding.
# Rebuilt 240 page(s); 0 queued.

cache:clear pages empties the page cache without rebuilding anything (and drops the queue).

optimize [--check] — write the boot manifest

Writes bootstrap/cache/boot.php (atomically): the merged config, the package discovery data and the catalogs (components, blocks, extensions, models, ...), so web requests with APP_DEBUG=0 read them instead of scanning. See preflow/core's README, "Boot manifest", for when a request uses it. Run it on every deploy after composer install and migrations / folio:extensions:apply, before the release goes live. With APP_DEBUG on it writes none (and removes an existing one). cache:clear (key boot) removes it.

optimize --check lists the sources changed since the manifest was written and exits with 1; it exits with 0 when the manifest is current and with 1 when there is none.

php preflow optimize
# Boot manifest written: /srv/site/releases/42/bootstrap/cache/boot.php
#   config: 18 file(s); catalogs: core.components, core.controllers, folio.extensions, …
php preflow optimize --check
# Boot manifest is current.

auth:token <email-or-id> — issue, list and revoke API tokens

Needs preflow/auth. Creates a bearer token for a user of the token guard (the auth.api_guard guard, else the first TokenGuard) and prints it once; only its hash is stored. Use it as Authorization: Bearer <token> on routes with TokenAuthMiddleware.

php preflow auth:token editor@example.com --name=deploy-bot
php preflow auth:token --list editor@example.com
php preflow auth:token --revoke=<uuid>

--name is a label (default cli).

auth:user <PREFIX> — set a login in .env

Asks for an email and a password (hidden, twice, at least 8 characters) and writes <PREFIX>_EMAIL and <PREFIX>_PASSWORD_HASH to .env, replacing the lines when they exist. The prefix is required (capital letters, digits and underscores); the password and its hash are never printed. config/auth.php reads the two values, e.g. for a ConfigUserProvider user.

php preflow auth:user ADMIN
php preflow auth:user EDITOR --email=editor@example.com
# Set EDITOR_EMAIL=editor@example.com and EDITOR_PASSWORD_HASH in .env.

Commands from packages and the app

A package brings its own console commands by listing the classes in its composer.json (constructed without arguments, so they implement Command\CommandInterface):

"extra": { "preflow": { "commands": ["Vendor\\Pkg\\Console\\PruneCommand"] } }

Console reads them from vendor/composer/installed.json, so the site needs no config/commands.php line (e.g. members:prune from preflow/members). A site's own config/commands.php is registered after and wins on a name clash. A class that is missing or not a command is skipped with a warning on stderr.