Console (CLI)
Lift ships a tiny PSR-friendly CLI framework — Lift\Console\Application — and the vendor/bin/lift binary, which comes with generators (make:controller, make:model, …), database tools (migrate, migrate:rollback), and the queue worker.
Mental model: a CLI app is just a collection of
Commandobjects keyed by name.Applicationparses argv, finds the matching command, calls itsexecute(Input, Output): intmethod.
Using vendor/bin/lift
After composer require malinichevvv/lift-php, the binary is on PATH for any project:
vendor/bin/lift # list all commands
vendor/bin/lift list # same thing
vendor/bin/lift help <command> # help for one command
vendor/bin/lift version # show framework version
Set up a shell alias if you'll be typing it a lot:
alias lift='vendor/bin/lift'
lift list
Built-in commands
| Group | Command | Purpose |
|---|---|---|
| make | make:controller <Name> |
Generate a controller class |
make:request <Name> |
Generate a FormRequest subclass |
|
make:resource <Name> |
Generate a JsonResource subclass |
|
make:model <Name> |
Generate an active-record model | |
make:middleware <Name> |
Generate a PSR-15 middleware | |
make:command <Name> |
Generate a Command subclass |
|
make:job <Name> |
Generate a queue job | |
make:event <Name> |
Generate an event class | |
make:test <Name> |
Generate a TestCase subclass |
|
make:migration <name> |
Generate a timestamped migration file | |
| migrate | migrate |
Run all pending migrations |
migrate:rollback [--steps=N] |
Roll back the last N batches (default 1) | |
migrate:reset |
Roll back every migration | |
migrate:fresh |
reset + migrate |
|
migrate:status |
Tabular state of every migration | |
| queue | queue:work [--queue=...] [--sleep=N] [--max-jobs=N] |
Start a queue worker (see Queues) |
queue:table |
Print SQL/migration to create the database-queue table | |
| routes | routes:list [--bootstrap=...] |
List every registered route in a table |
| app | serve [--port=8000] |
Boot php -S on public/ |
key:generate |
Print a random APP_KEY (base64-encoded 32 bytes) |
|
repl |
Start an interactive PHP REPL with app context |
Most generators accept these flags:
lift make:controller AdminController --namespace=App\\Admin --path=src/Admin
| Flag | Default | Purpose |
|---|---|---|
--namespace=… |
App |
PHP namespace of the generated class |
--path=… |
src |
Directory written to (relative to CWD) |
--force |
off | Overwrite existing files |
Generated files are intentionally minimal — they're starting points, edit freely.
make:test — generate test classes
lift make:test UserTest
# → src/Tests/UserTest.php
lift make:test Feature/OrderFlowTest --namespace=Tests\\Feature
# → src/Tests/Feature/OrderFlowTest.php
The generated stub extends Lift\Testing\TestCase:
final class UserTest extends TestCase
{
public function testExample(): void
{
$this->assertTrue(true);
}
}
TestCase ships HTTP helpers ($this->get(...), $this->post(...), $this->assertStatus(200)) — see Testing for the full API.
repl — interactive PHP REPL
lift repl drops you into a live PHP interpreter with your app already loaded:
$ lift repl
Lift REPL — type PHP and press Enter. Type exit or Ctrl+D to quit.
$app is available.
>>> $app->configuration()->get('app.name')
"My App"
>>> $app->db()->table('users')->count()
42
>>> $u = new App\Models\User(); $u->name = 'Alice'
>>> $u
App\Models\User {"name":"Alice"}
>>> exit
Bye!
How it works
-
Lift looks for bootstrap files in this order:
bootstrap/app.phpapp/bootstrap.phpapp.php
If found, it requires the file and makes the returned value available as
$app. -
Each line is attempted as an expression first (
return (…);). If it parses, the return value is printed using compact var_export-style output. If it doesn't parse (assignment, control flow, etc.), the line is executed as a statement. -
Variables persist across iterations — set
$x = 5on one line, use$xon the next. -
Multi-line input: end a line with
\to continue on the next line.
>>> $users = $app->db()
... ->table('users')
... ->where('active', true)
... ->get() \
... ->pluck('email')
["[email protected]","[email protected]"]
Flags
| Flag | Purpose |
|---|---|
--bootstrap=path |
Explicit bootstrap file (overrides auto-detection) |
Example:
lift repl --bootstrap=app/bootstrap.php
lift repl --bootstrap=/var/www/myapp/bootstrap/app.php
History is saved to ~/.lift_repl_history and loaded on next launch, so you can arrow-up through previous sessions.
Requirements: The readline PHP extension must be installed (php-readline on most Linux distros). The REPL will tell you if it's missing.
Practical REPL examples
Check configuration
>>> $app->configuration()->get('app.name')
"My App"
>>> $app->configuration()->get('database.connections.mysql.host')
"localhost"
>>> $app->environment()
"local"
Query the database
>>> $app->db()->table('users')->count()
42
>>> $app->db()->table('users')->where('active', true)->get()
[{"id":1,"email":"[email protected]","name":"Alice"}, ...]
>>> $app->db()->table('orders')->where('status', 'pending')->count()
7
Test a model
>>> $user = $app->container()->get(\App\Models\User::class)
>>> $user->find(1)
App\Models\User {"id":1,"email":"[email protected]","name":"Alice","active":true}
>>> $user->find(9999)
null
Check routes
>>> $app->router()->getRoutes()
[{"path":"/","method":"GET","handler":"App\Http\Controllers\HomeController@index","name":"home"}, ...]
>>> $app->router()->match('GET', '/users/42')
{"path":"/users/{id}","handler":"App\Http\Controllers\UserController@show","params":{"id":"42"}}
Work with cache
>>> $cache = $app->container()->get(\Psr\SimpleCache\CacheInterface::class)
>>> $cache->get('user:42:profile')
{"id":42,"name":"Alice","role":"admin"}
>>> $cache->set('test_key', 'test_value', 60)
true
>>> $cache->get('test_key')
"test_value"
One-off task — create a user
>>> $app->db()->table('users')->insert([
... 'email' => '[email protected]',
... 'name' => 'Admin',
... 'password_hash' => password_hash('secret', PASSWORD_BCRYPT),
... 'created_at' => time(),
... ])
true
>>> $app->db()->table('users')->where('email', '[email protected]')->first()
{"id":43,"email":"[email protected]","name":"Admin",...}
Test a service
>>> $payment = $app->container()->get(\App\Services\PaymentService::class)
>>> $payment->charge(1000, 'tok_visa')
{"id":"ch_1234","amount":1000,"status":"succeeded"}
Check container bindings
>>> $app->container()->has(\App\Services\PaymentService::class)
true
>>> $app->container()->make(\Lift\Http\Request::class)
Lift\Http\Request {...}
Debug a variable
>>> $data = ['foo' => 'bar', 'nested' => ['a' => 1, 'b' => 2]]
>>> $data
{"foo":"bar","nested":{"a":1,"b":2}}
Try an API call
>>> $client = $app->container()->get(\Lift\Http\HttpClient::class)
>>> $client->get('https://api.github.com/repos/malinichevvv/lift-php')->json()
{"id":123456789,"name":"lift-php","full_name":"malinichevvv/lift-php",...}
When to use REPL vs alternatives
| Task | REPL | CLI command | Test | Script |
|---|---|---|---|---|
| Quick experiment with API | ✅ Best | ❌ Overkill | ❌ Slow | ❌ Boilerplate |
| One-off data fix | ✅ Good | ✅ Better if reusable | ❌ No | ✅ Good for complex |
| Debugging production (cautiously) | ✅ Possible | ✅ Safer | ❌ No | ❌ No |
| Repeated task | ❌ No history | ✅ Perfect | ❌ No | ✅ Yes |
| Complex logic | ❌ No undo | ✅ Versioned | ✅ Verified | ✅ Versioned |
REPL limitations
- No undo — if you delete data with
$app->db()->table('x')->delete(), it's gone. Be careful in production. - No persistence — REPL sessions don't save state. Reload to start fresh.
- Global state — changes affect the PHP process only. Other workers/processes won't see them.
- Long-running state — if you open a transaction and forget to commit/rollback, it stays open until REPL exits.
Adding your own commands
Lift\Console\Application accepts any subclass of Lift\Console\Command. Drop a file in bin/:
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Lift\Console\Application;
use Lift\Console\Command;
use Lift\Console\Input;
use Lift\Console\Output;
final class CleanCacheCommand extends Command
{
public function getName(): string { return 'cache:clear'; }
public function getDescription(): string { return 'Wipe the application cache'; }
public function execute(Input $input, Output $output): int
{
$output->write('Clearing cache… ');
// …actual work…
$output->success('done');
return 0;
}
}
$app = require __DIR__ . '/../app/bootstrap.php'; // your Lift app
$cli = new Application('myapp', '1.0.0');
$cli->register($app->make(CleanCacheCommand::class)); // DI-resolved
exit($cli->run());
Make it executable: chmod +x bin/myapp. Run it: ./bin/myapp cache:clear.
Convention: namespace your commands
Use : in the command name to group commands in list. lift list will print them under headings:
cache
cache:clear Wipe the application cache
cache:warmup Pre-build the page cache
db
db:seed Run the seeders
The Command base class
abstract class Command
{
abstract public function getName(): string; // e.g. 'cache:clear'
abstract public function getDescription(): string; // one-line summary
abstract public function execute(Input $i, Output $o): int; // exit code
public function getHelp(): string { return $this->getDescription(); } // optional long help
}
Return 0 from execute() on success, non-zero on failure. The exit code is what Application::run() returns — perfect for shell scripts:
lift migrate || { echo 'migration failed'; exit 1; }
Input — reading argv
$input->getCommand(); // 'migrate'
$input->getArgument(0, 'default'); // first positional argument
$input->getArguments(); // all positional args (excluding command)
$input->getOption('queue', 'default'); // --queue=foo or 'default'
$input->hasOption('force'); // --force was passed?
Argv parsing rules:
--name=value→ optionnamewith string value.--name→ optionnamewithtruevalue.-X(single char) → optionXwithtrue.- Anything else, in order, becomes the command and then positional arguments.
There are intentionally no required-argument declarations — read the args you need, fall back to defaults, fail with a useful message:
public function execute(Input $i, Output $o): int
{
$name = $i->getArgument(0);
if ($name === '') {
$o->error('Usage: lift make:foo <name>');
return 1;
}
// …
}
Output — writing to stdout/stderr with colour
Markup-style tags inside strings — <green>, <yellow>, <red>, <cyan>, <bold>, <grey> — are converted to ANSI escapes only when stdout is a TTY. In a pipe (lift foo | grep …) or in tests, the tags are stripped.
$o->writeln('Hello');
$o->writeln('<green>Success</green> in <bold>0.4s</bold>');
$o->write('Working… '); // no newline
$o->writeln('done');
$o->success('All clear.'); // green
$o->warn('Slow query detected'); // yellow
$o->error('Boom'); // red, → stderr
$o->info('Heads up'); // cyan
Tables
$o->table(
headers: ['ID', 'Email', 'Active'],
rows: [
[1, '[email protected]', 'yes'],
[2, '[email protected]', 'no'],
],
);
Auto-sizes columns to the widest cell. Use it for migrate:status-style output.
Standalone CLI (without Lift app)
Lift\Console\Application doesn't depend on Lift\App. You can use it on its own:
use Lift\Console\Application;
$cli = new Application('mytool', '0.1.0');
$cli->register(new GenerateReadmeCommand());
$cli->register(new CheckLinksCommand());
exit($cli->run());
Great for project-specific tooling without the HTTP stack.
Real-world example — daily cron job
bin/daily.php:
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
use Lift\Console\Application;
use Lift\Console\Command;
use Lift\Console\Input;
use Lift\Console\Output;
$app = require __DIR__ . '/../app/bootstrap.php';
final class PurgeOldSessions extends Command
{
public function __construct(private readonly \Lift\Database\Connection $db) {}
public function getName(): string { return 'purge:sessions'; }
public function getDescription(): string { return 'Delete sessions older than 30 days'; }
public function execute(Input $i, Output $o): int
{
$days = (int) $i->getOption('days', 30);
$cut = time() - 86400 * $days;
$n = $this->db->execute('DELETE FROM sessions WHERE last_activity < ?', [$cut]);
$o->success("Purged {$n} stale session(s).");
return 0;
}
}
$cli = new Application('daily', '1.0.0');
$cli->register($app->make(PurgeOldSessions::class));
exit($cli->run());
Crontab:
0 3 * * * cd /var/www/myapp && php bin/daily.php purge:sessions --days=30
Testing commands
Output accepts custom stream resources, so tests can capture stdout/stderr without forking a process:
public function testItPrintsHello(): void
{
$out = fopen('php://memory', 'r+');
$err = fopen('php://memory', 'r+');
$cmd = new MyCommand();
$exit = $cmd->execute(new Input(['arg']), new Output($out, $err));
rewind($out);
self::assertSame(0, $exit);
self::assertStringContainsString('Hello', stream_get_contents($out));
}
Color tags are auto-stripped when Output doesn't think it's on a TTY — your assertions stay free of ANSI escapes.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Command 'foo' not found |
Forgot to register() it |
Add it to your CLI bootstrap. |
Colours show as <green>... literally |
Output detects no TTY (piped, redirected) | Expected — colours show only on real terminals. |
| Long command output isn't flushed | fwrite() to stdout is line-buffered |
fflush(STDOUT) after writing — or use Output::writeln() which writes whole lines. |
Cannot resolve parameter $db when registering |
The CLI bootstrap forgot to wire DI | Build commands through the container: $cli->register($app->make(MyCmd::class)). |
lift queue:work exits with code 0 immediately |
No queue driver configured ⇒ SyncQueue::pop() always returns null and sleep ticks forever — but it does keep going. Symptom is "nothing happens" |
Configure a real driver (Redis, DB). |
pcntl_signal not available in worker |
Compiled PHP lacks pcntl | Install php-pcntl; ungraceful shutdown still works. |
Cheat sheet
// Make a command
final class MyCmd extends Command
{
public function getName(): string { return 'my:cmd'; }
public function getDescription(): string { return 'Does X'; }
public function execute(Input $i, Output $o): int { /* … */ return 0; }
}
// Read input
$i->getCommand() / getArgument(0) / getArguments() / getOption('name') / hasOption('force');
// Write output
$o->writeln('plain');
$o->success('green'); $o->warn('yellow'); $o->error('red, → stderr'); $o->info('cyan');
$o->table(['a','b'], [[1,2]]);
// Boot a CLI
$cli = new Application('myapp', '1.0.0');
$cli->register($cmd);
exit($cli->run());
// Built-in commands
vendor/bin/lift list / version / help <cmd>
vendor/bin/lift make:controller|request|resource|model|middleware|command|job|event|test <Name>
vendor/bin/lift make:migration <name>
vendor/bin/lift migrate / migrate:rollback / migrate:fresh / migrate:status
vendor/bin/lift queue:work
vendor/bin/lift routes:list
vendor/bin/lift key:generate
vendor/bin/lift serve --port=8000
vendor/bin/lift repl [--bootstrap=path/to/app.php]