Kettle
kettle is the command-line front end to a Pop application. It scaffolds the project, configures and
migrates databases, runs queue workers, builds front-end assets and starts a development server — and
the commands you write yourself show up in the same list as the ones it ships with. It's the
popphp/pop-kettle component, installed with the framework and driven by a kettle script sitting in
your project root.
What Kettle Is#
Kettle is a Pop console application like any other, and the script that boots it is short enough to read in one sitting:
#!/usr/bin/env php
<?php
/**
* Pop Kettle Console Application
*/
$autoloader = include __DIR__ . '/vendor/autoload.php';
$dotEnv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotEnv->safeLoad();
try {
$config = include __DIR__ . '/vendor/popphp/pop-kettle/config/app.console.php';
// Load any custom application command routes
$config['routes'] = Pop\Console\CommandRegistry::loadRoutes($config['routes'], __DIR__ . '/app/src/Console/Command/Kettle');
$app = new Pop\Kettle\Application($autoloader, $config);
$app->prepare()
->load()
->run();
} catch (\Throwable $exception) {
$app = new Pop\Kettle\Application();
$app->cliError($exception);
}
Pop\Kettle\Application extends Pop\Application, and its route table is a plain CLI routes config
living inside the component. The .env file is loaded before the application is constructed, so
APP_ENV, MAINTENANCE_MODE and the database variables are in the environment by the time any command
runs. Pop\Console\CommandRegistry::loadRoutes() scans app/src/Console/Command/Kettle and merges
whatever it finds into that route table — which is how your own commands become Kettle commands, covered
under Writing your own commands.
Command names are namespaced with a colon, and the namespace tells you which part of the application a command touches.
| Namespace | Covers |
|---|---|
pop: |
initializing the application, its environment and maintenance mode |
db: |
connection configuration, seeding, import and export |
migrate: |
schema migrations |
queue: |
queue configuration, workers, the scheduler and inspection |
create: |
scaffolding commands, controllers, models and views |
web: |
the development server and front-end asset builds |
help and version carry no namespace, and neither do the commands you add yourself unless you put a
colon in the name.
Run ./kettle from the project root — every command resolves the project through getcwd() rather than
through the script's own location.
The script ships executable, so ./kettle db:test and php kettle db:test are equivalent.
Initializing an Application#
pop:init builds the application skeleton. It takes no arguments and no flags — everything is gathered
through prompts, and Composer offers to run it for you at the end of composer create-project.
./kettle pop:init
The first answer is the namespace, defaulting to App. Whatever you type is normalized into a valid PHP
namespace: each \- or /-separated segment is split on hyphens, underscores and camelCase boundaries
and re-cased, so my-user-app becomes MyUserApp and My\Users\App stays three segments. The
application's display name is offered next, defaulting to a readable form of the namespace —
my-user-app produces a default of My User App.
Quote a namespace containing backslashes: "My\Users\App". Bare at a bash prompt the shell eats them,
and you get a single segment.
Three answers then decide how much scaffolding you get.
| Prompt | Answering yes adds |
|---|---|
Is this a CLI-only application? [Y/N] |
nothing — it removes the web files. No public/, no app/view/, no app/src/Http/, and the URL and front-end prompts are skipped entirely |
Initialize a stand-alone CLI application? [Y/N] |
app/src/Console/Controller/ and a script/<slug> entry point, the slug being a kebab-case form of the namespace |
Would you like to configure a database? [Y/N] |
app/config/database.php and the database/ tree, filled in through the same prompts db:config uses |
Whichever branch you take, pop:init always writes .env, app/src/Application.php,
app/config/app.console.php and an empty app/src/Console/Command/Kettle/ directory. It then registers
your namespace in the autoload.psr-4 map in composer.json and runs composer dump-autoload, so
kettle, public/index.php and any stand-alone script all resolve your classes through the one
Composer autoloader. If Composer is not on your PATH the command still finishes and warns you to run
composer dump-autoload yourself.
The prompts are walked through one at a time in Your First Application, and the directories they produce are mapped in Application Structure.
Say yes to the stand-alone CLI application at pop:init if you might want one later — create:ctrl --cli or
create:command -a need that scaffolding in place.
Creating Application Files#
The create: commands write skeleton class files into the right directory under your own namespace, so
you're not copying an existing file and renaming the class inside it.
./kettle create:ctrl Widget
./kettle create:ctrl --cli Widget
./kettle create:model Widget
./kettle create:model --data Gadget
./kettle create:view widgets/index.phtml
./kettle create:command widget:sync
| Command | Writes |
|---|---|
create:ctrl <ctrl> |
app/src/Http/Controller/<ctrl>.php, extending your app's Http\Controller\AbstractController |
create:ctrl --cli <ctrl> |
app/src/Console/Controller/<ctrl>.php, extending your app's Console\Controller\AbstractController |
create:model <model> |
app/src/Model/<model>.php, extending Pop\Utils\AbstractModel |
create:model --data <model> |
the same file extending Pop\Db\Model\AbstractDataModel, plus a table class |
create:view <view> |
a view file under app/view/ |
create:command <command> |
a Kettle command class — see Writing your own commands |
create:command -a <command> |
a command class for your stand-alone CLI application — see Writing your own commands |
Each one prints the fully qualified name it produced, so you can confirm the namespace came out right:
Controller class 'App\Http\Controller\Widget' created.
--data writes the pair a data model needs: ./kettle create:model --data Gadget gives you
App\Model\Gadget extending Pop\Db\Model\AbstractDataModel and App\Table\Gadgets extending
Pop\Db\Record. The table name appends an s unless the name already ends in one, so rename
App\Table\Categorys when the naive form is wrong. See Records & the ORM and
Models.
Give create:view the full filename including the extension — ./kettle create:view widgets/index.phtml.
The generated classes are bodies-only — an empty class in the right namespace extending the right
parent. Nothing registers them: a controller still needs a route in app/config/app.http.php
(see Routing), and a view still needs a controller rendering it
(see Views & Templates). The exception is create:command, whose output is discovered
automatically.
Managing the Database#
Every db: and migrate: command takes an optional <database> argument naming one connection in
app/config/database.php. Leave it off, and you get default; pass all and the command runs against
every connection that has a directory under database/migrations/.
./kettle db:config
./kettle db:config reporting
./kettle db:test all
./kettle db:install
db:config creates a connection. It lists the adapters your PHP build has and asks for the credentials that
adapter needs, testing the connection before writing anything:
Database configuration test failed. Please try again.
PDO Connection Error: SQLSTATE[08006] [7] connection to server at "localhost" (127.0.0.1), port 5432 failed: FATAL: role "nick" does not exist (#7)
A SQLite adapter asks only for a name, appends .sqlite if you left it off, and creates the file under
database/.
Configuring a connection called reporting rather than default writes DB_REPORTING_* variables into
.env, adds a 'reporting' key to app/config/database.php reading them, and creates
database/migrations/reporting, database/seeds/reporting and database/snapshots/reporting. The
default connection uses unprefixed DB_* variables. That naming is the whole multi-database
mechanism — nothing else has to be told a second connection exists.
| Command | Does |
|---|---|
db:config [<database>] |
prompts for the adapter and credentials and writes them to .env and app/config/database.php |
db:test [<database>] |
opens the configured connection and reports whether it worked |
db:install [<database>] |
db:config followed by db:seed, for setting a connection up from nothing |
db:create-seed <seed> [<database>] |
writes a seeder class template into database/seeds/<database>/ |
db:seed [<database>] |
runs every .sql file and seeder class in database/seeds/<database>/ |
db:reset [<database>] |
empties every table, re-runs the seeds, and forgets the migration position |
db:clear [<database>] |
drops every table and forgets the migration position |
db:export [<database>] |
mysqldump into database/snapshots/<database>/<name>-<timestamp>.sql |
db:import <file> [<database>] |
pipes a .sql file back in through the mysql client |
db:export and db:import shell out to mysqldump and mysql, so they are MySQL-only, and db:import
resolves <file> relative to the project root. For an adapter-neutral export, see Schema &
Migrations.
Application Status#
Two .env variables decide how the application behaves as a whole: APP_ENV, one of local, dev,
testing, staging or production, and MAINTENANCE_MODE, true or false. Four commands read and
write them.
./kettle pop:env
./kettle pop:env --set
./kettle pop:status
./kettle pop:down
./kettle pop:up
pop:env prints the current environment as a colored box; pop:env --set offers the five values as a
numbered list and writes the one you pick. Only those five are accepted, so APP_ENV cannot be set to
something the framework does not recognize:
1: local
2: dev
3: testing
4: staging
5: production
Please select an app environment from above:
pop:down sets MAINTENANCE_MODE=true and pop:up sets it back. pop:status reports which of the two
you're in. Add --secret to pop:down and it generates a token, writes it to
MAINTENANCE_MODE_SECRET and prints it:
Application has been switched to maintenance mode.
The secret is 5d1ae97fdd885b8507dc14113dffc100a6018392
--secret=LETMEIN uses the value you give instead of generating one. Visiting
http://localhost:8000/?secret=LETMEIN once stores it in a cookie, so that browser keeps reaching the
application while everyone else gets the maintenance response. What the application does with the flag —
which controller action answers, and how a health check opts out — is in
Controllers.
Both variables also change how kettle itself behaves, on every command rather than only the four
above. While maintenance mode is on, each command prints an info banner ahead of its own output, and
pop:up is the one command exempt from it:
Application in Maintenance
Version: 3.0.0
With APP_ENV=production, kettle confirms before running most commands. Eight read-only ones skip the
prompt: pop:env, pop:status, help, version, and the queue inspection commands.
Queue Commands#
Kettle configures queues and runs workers against them. Putting jobs and tasks into a queue is your
application's job — $queue->addJob() and $queue->addTask(), wherever that belongs in your code — and
is covered in Queues & Scheduled Tasks.
queue:config sets a queue up the way db:config sets a connection up. It offers the adapters
available to you — File and Database always, Redis when the redis extension is loaded — then
asks for that adapter's settings, a FIFO/FILO priority, a lease length in seconds and a weight.
1: File
2: Database
3: Redis
Please select one of the above queue adapters:
Queue Folder: [data/queue/default]
Queue Priority (FIFO/FILO): [FIFO]
Lease Seconds: [60]
Queue Weight: [0]
The answers land in .env as QUEUE_* and in app/config/queue.php as a 'default' block reading
them. A queue configured under another name gets QUEUE_<NAME>_* variables and its own config block,
matching how db:config <database> behaves.
| Command | Does |
|---|---|
queue:config [<queue>] |
prompts for the adapter and settings and writes .env and app/config/queue.php |
queue:work [-o|--once] [-s|--sleep=] [<queue>] |
works pending jobs |
queue:scheduler [-o|--once] [-s|--sleep=] [<queue>] |
runs due scheduled tasks |
queue:jobs [<queue>] |
pending and dead-letter counts, and every dead job with its failure reason |
queue:tasks [<queue>] |
scheduled tasks with their cron expression and grace period |
queue:clear [-f|--failed] [-t|--tasks] [<queue>] |
clears pending and leased jobs, or the dead-letter store, or the tasks |
queue:jobs and queue:tasks are the two you reach for most, because they are the only view into a
queue that does not involve running it:
Queue 'default':
Pending: 0
Dead: 1
- a1e0d3a0ff9ede2d27970334f413856a099f583e (nope)
Queue 'default':
- 8ffdc3075221b818e3db3a562eaf942cb3897a41: 0 2 * * * (grace: -1s)
Without --once, queue:work and queue:scheduler run as daemons until signaled, with --sleep= setting
the idle wait in seconds (default 1). With --once each does a single pass and exits, which is the shape
for a cron entry. Passing all as <queue> services every configured queue in weight order.
queue:work and queue:scheduler are separate loops — a worker never runs scheduled tasks and a
scheduler never works jobs — so an application using both runs two processes.
queue:clear clears pending and leased jobs, including ones a running worker has reserved. --failed
clears the failed list instead.
A queued job that names an application command runs through your application, not through Kettle. A job
created as Job::command('send-email nick@test.com --cc=ops@test.com') and worked by
./kettle queue:work --once dispatches your send-email command with $this->application set to
App\Application. Its output is captured onto the job as its results rather than printed, so a silent
Worker pass complete. does not mean nothing ran.
Front-End Assets#
A full install offers a front-end at pop:init time — AlpineJS, Vue or React. Each comes with Tailwind CSS
v4 through the @tailwindcss/vite plugin and Vite as the build tool. Sources land in app/assets/css and
app/assets/js, with package.json and vite.config.js beside kettle. pop:init runs npm install and
npm run build for you.
./kettle web:watch
./kettle web:build
web:watch runs npm run watch, which is vite build --watch — it rebuilds to disk on every save.
There's no dev server and no hot-module reload, so you refresh the browser yourself. web:build runs
the one-shot npm run build. Both are thin wrappers, and running the npm scripts directly does the same
thing.
The build output paths are fixed rather than content-hashed: public/assets/js/app.js and
public/assets/css/app.css, in a watch build and a production build alike. That's deliberate — the
<link> and <script> tags pop:init writes into app/view/index.phtml never have to change between
the two.
Both commands check for package.json before doing anything, so a CLI-only install, or a full install
where you declined the front-end, gets a plain message rather than an npm error:
No front-end has been installed for this project.
If package.json is there but npm is not on your PATH, the message is
Node/npm was not found on your PATH. instead. pop:init behaves the same way — a missing Node does
not fail the scaffolding, it warns and leaves npm install for you.
Running the Web Server#
web:serve starts PHP's built-in web server against the project, so there's nothing to configure while
you're developing:
./kettle web:serve
./kettle web:serve --host=0.0.0.0 --port=8080 --folder=web
PHP web server running on the folder public at localhost:8000... (Ctrl-C to stop)
Host defaults to localhost, port to 8000, and the document root to public. What the server does
with a request, and the two constraints worth knowing before leaning on it, are covered in
Installation.
This is a development tool. PHP's own documentation says the built-in server is not intended for
production, and nothing about web:serve changes that — put a real web server in front of the
application anywhere it matters.
Note A CLI-only application has no
public/folder or front-end assets, so theweb:*commands are not meant to be used in that situation.
Writing Your Own Commands#
An application command lives in one of two places. A Kettle command registers with the kettle script
and runs as ./kettle <command>, showing up in ./kettle help as an available command. A stand-alone
console script is a separate CLI application of your own at script/<slug>, run as ./script/<slug> <command>.
create:command writes into one or the other depending on a flag.
./kettle create:command send-email
./kettle create:command --app report:daily
Without --app, the class goes into app/src/Console/Command/Kettle/ and is discovered by kettle.
With --app, it goes into app/src/Console/Command/ and is discovered by the stand-alone script. A
command in the wrong directory is not a broken command, it's an invisible one:
./kettle report:daily on the --app command above answers Invalid Command.
The <command> argument is the command signature, and the class name comes from it — title-cased, and
taken from the part after the last colon. send-email gives you SendEmail; email:send gives you
Send registered under the full email:send. A command is one class with one handle() method:
<?php
namespace App\Console\Command\Kettle;
class SendEmail extends \Pop\Console\Command\AbstractCommand
{
public ?string $name = 'send-email';
public ?string $params = '<to> [--cc=]';
public ?string $help = 'Send the welcome email to an address';
public function handle(string $to, array $options = [])
{
$this->console->write('To: ' . $to);
$this->console->write('CC: ' . ($options['cc'] ?? 'none'));
}
}
$params uses the same CLI route syntax as any other console route — <required>, [<optional>],
[--flag], [-c|--cc=] — and the parsed values arrive in handle() exactly as they arrive in a console
controller action: parameters positionally, options together in a trailing array. The full syntax table
is in Routing. $help is the description that shows up beside the command in
./kettle help.
Nothing registers the class. Pop\Console\CommandRegistry::loadRoutes(), called in the kettle script
and in the stand-alone script, scans the directory on every run and merges what it finds into the route
table ahead of the built-in commands:
./kettle send-email <to> [--cc=] Send the welcome email to an address
./kettle pop:init Initialize an application
Keep every command class in one directory under one namespace — loadRoutes() reads the namespace from
the first file it finds and applies it to the rest.
The stand-alone script is the other shape. It's a complete separate application with its own entry point,
its own route config in app/config/app.console.php, and its own Console\Controller\ classes for
grouping related commands under one class:
#!/usr/bin/env php
<?php
$autoloader = include __DIR__ . '/../vendor/autoload.php';
$dotEnv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../');
$dotEnv->safeLoad();
try {
// Load any custom application command routes
$config = include __DIR__ . '/../app/config/app.console.php';
$config['routes'] = Pop\Console\CommandRegistry::loadRoutes($config['routes'], __DIR__ . '/../app/src/Console/Command');
$app = new App\Application($autoloader, $config);
$app->load();
$app->run();
} catch (\Throwable $exception) {
$app = new App\Application();
$app->cliError($exception);
}
It's the same shape as the kettle script, pointed at your own Application class and your own route
config. ./script/app help lists what it knows about:
./app report:daily This is the report:daily command
./app help Show the help screen
Reach for a Kettle command for a handful of one-offs — it is available the moment the file exists. Reach for the stand-alone script when the CLI side is an application in its own right, with enough commands to group into controllers. See Building Console Applications.
Accessing the Application from a Command#
A Kettle command runs through kettle but not as Kettle. For a route whose controller is not a
Pop\Kettle\* class, Kettle resolves your own Application from app/src/Application.php and hands
execution to it — so the header is your application's name and $this->application() is your object:
App
===
Application: App\Application
Environment: staging
Pop\Console\Command\AbstractCommand uses Pop\Dispatch\ConsoleTrait, so every command has
$this->application and $this->console as properties, and application(), console(),
getApplication() and getConsole() as accessors. Whatever your own Application::load() registers —
services, event listeners, config — is registered by the time handle() runs.
<?php
namespace App\Console\Command\Kettle;
class Sync extends \Pop\Console\Command\AbstractCommand
{
public ?string $name = 'widget:sync';
public ?string $help = 'Sync widgets from the upstream feed';
public function handle()
{
$feed = $this->application()->getService('feed');
$this->console->write('Syncing from ' . \Pop\App::env('FEED_URL'));
}
}
Pop\App is the static accessor for the same application — App::environment(), App::isProduction(),
App::env('KEY') — and it's available anywhere, including in a class a command calls rather than in the
command itself.
A command reaches the database the same way it reaches any other service — through the application. Both
kettle and a stand-alone script/<slug> build that application from app/config/app.console.php, so
that file needs a 'database' => include __DIR__ . '/database.php', entry for load() to open a
connection.
Answering Y to the database prompt at pop:init writes the line for you. Add a database later with
db:config and you add the line yourself — it writes app/config/database.php and the .env values and
stops there. See Configuration for the HTTP side, and
Applications & Bootstrap for what load() does with the key.
Help and Version#
./kettle help prints every command with its signature and description, your own commands first, and
./kettle version prints the installed pop-kettle version. Two options narrow the output.
./kettle help
./kettle help db
./kettle help --raw
./kettle help --raw db
A <command> argument filters to commands whose name starts with what you typed. The trailing colon is
optional, so help queue and help queue: do the same thing, and a full command name narrows the list
to that one line:
./kettle send-email <to> [--cc=] Send the welcome email to an address
Column widths are recalculated for whatever is left, which is why a filtered list lines up more tightly than the full one. A filter that matches nothing prints an empty list rather than an error.
--raw (or -r) drops the ANSI color codes, which is what you want when piping the output somewhere
that would show them as escape sequences. The two combine freely: ./kettle help --raw db.
Kettle on Windows#
The kettle script opens with a #!/usr/bin/env php shebang, which is what lets a UNIX-like shell run
it directly as ./kettle. Windows does not read shebang lines, so call PHP explicitly and pass the
script as its first argument:
C:\my-app>php kettle help
Everything after that is identical — arguments, options and output all behave the same way. Every
example on this page is written as ./kettle, so read that as php kettle throughout if you are on
Windows.
One detail does change with the shell rather than the OS. The advice to quote a namespace containing
backslashes at pop:init exists because bash strips them; cmd.exe does not treat \ as an escape
character, so My\Users\App typed there arrives intact. Quoting it anyway is harmless and works in
both.
Shell Completion#
Completion scripts for bash and zsh ship inside the component. Copy the one for your shell into your home directory and source it from your shell's startup file.
cp vendor/popphp/pop-kettle/.kettle.bash ~/
echo 'source ~/.kettle.bash' >> ~/.bashrc
cp vendor/popphp/pop-kettle/.kettle.zsh ~/
echo 'source ~/.kettle.zsh' >> ~/.zshrc
Open a new terminal, change into any project with a kettle script, and ./kettle db:<Tab> completes.
Both scripts build their word list from php kettle help --raw, so completions are whatever that project's
kettle offers, your own commands included. Completion runs a PHP process per <Tab>, and works from a
directory holding a kettle script.
Kettle exposes more than this page covers — the full option list for every command, and the exact prompts each one asks — see the pop-kettle README.
See Also#
- Installation —
composer create-project, and the dev server in more detail - Your First Application — the
pop:initprompts answered one at a time - Application Structure — the directories
pop:initwrites - Migrations — the
migrate:commands in depth - Seeding — the
db:seedcommand and the seed files it runs - Building Console Applications — writing the console application
create:ctrl --cliscaffolds - Queues & Scheduled Tasks — the jobs and tasks
queue:workandqueue:schedulerrun - pop-kettle README — the full command reference